├── .npmrc ├── src ├── routes │ ├── __layout.svelte │ └── index.svelte ├── app.scss ├── app.html ├── stores │ ├── params.js │ ├── clock.js │ └── data.js ├── utils │ ├── geometry.js │ ├── format.js │ ├── actions.js │ ├── math.js │ └── webgl.js ├── lib │ ├── animation │ │ ├── Background.svelte │ │ ├── Athlete.svelte │ │ ├── Animation.svelte │ │ ├── SpeedskatingArena.svelte │ │ ├── IntermediateDisplay.svelte │ │ ├── Athletes.svelte │ │ └── Track.svelte │ ├── sources │ │ └── Sources.svelte │ ├── utils │ │ ├── TextAlignEmbed.svelte │ │ ├── Sizer.svelte │ │ └── SizeDetector.svelte │ ├── controls │ │ ├── TimeDisplay.svelte │ │ ├── PlayButton.svelte │ │ ├── ProgressBar.svelte │ │ └── Controls.svelte │ ├── overlay │ │ ├── NumberSwitcher.svelte │ │ ├── Countdown.svelte │ │ ├── Overlay.svelte │ │ └── Results.svelte │ └── header │ │ └── Header.svelte └── variables.scss ├── static ├── favicon.png ├── preview.png └── config.json ├── .gitignore ├── .prettierrc ├── .eslintrc.cjs ├── jsconfig.json ├── svelte.config.js ├── package.json ├── README.md └── LICENSE /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /src/routes/__layout.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spiegelgraphics/speedskating/HEAD/static/favicon.png -------------------------------------------------------------------------------- /static/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spiegelgraphics/speedskating/HEAD/static/preview.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /src/app.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | html, body { 8 | font-family: Arial, sans-serif; 9 | font-size: 16px; 10 | } -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['eslint:recommended', 'prettier'], 4 | plugins: ['svelte3'], 5 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 6 | parserOptions: { 7 | sourceType: 'module', 8 | ecmaVersion: 2020 9 | }, 10 | env: { 11 | browser: true, 12 | es2017: true, 13 | node: true 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "$lib": ["src/lib"], 6 | "$lib/*": ["src/lib/*"], 7 | "$utils": ["src/utils"], 8 | "$utils/*": ["src/utils/*"], 9 | "$stores": ["src/stores"], 10 | "$stores/*": ["src/stores/*"] 11 | } 12 | }, 13 | "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] 14 | } 15 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %svelte.head% 9 | 10 | 11 |
%svelte.body%
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/stores/params.js: -------------------------------------------------------------------------------- 1 | import { readable } from 'svelte/store'; 2 | 3 | const defaultParams = { 4 | config_path: 'config.json', 5 | min_height: 320, 6 | max_height: 500, 7 | left_right_padding: 0, 8 | text_align_disabled: true, 9 | intermediate_times: false, 10 | debug: false 11 | }; 12 | 13 | export const params = readable(defaultParams, set => { 14 | // here we usually inject parameters from the CMS 15 | const completeParams = { 16 | ...defaultParams 17 | // CMS parameters 18 | }; 19 | set(completeParams); 20 | }); -------------------------------------------------------------------------------- /src/utils/geometry.js: -------------------------------------------------------------------------------- 1 | const pathToArray = path => path.split(/(?=[a-zA-Z])/g); 2 | 3 | const stripFirstPathOperation = path => { 4 | const arr = pathToArray(path); 5 | return arr.slice(1).join(''); 6 | }; 7 | 8 | export const fusePaths = (paths) => { 9 | if (!paths && !paths.length) return null; 10 | 11 | const start = paths[0]; 12 | const others = paths 13 | .slice(1) 14 | .map(d => stripFirstPathOperation(d)); 15 | 16 | const fused = [start, ...others].reduce((acc, cur) => `${acc}${cur}`, ''); 17 | 18 | return fused; 19 | }; 20 | 21 | export const getDistance = ({ x: x1 = 0, y: y1 = 0 }, { x: x2 = 0, y: y2 = 0 }) => { 22 | return Math.hypot(x2 - x1, y2 - y1); 23 | }; -------------------------------------------------------------------------------- /src/lib/animation/Background.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
15 | {#if (type === 'speedskating')} 16 | 17 | {/if} 18 |
19 | 20 | -------------------------------------------------------------------------------- /src/lib/sources/Sources.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 |
13 |

14 | {sources} 15 |

16 |
17 |
18 | 19 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import preprocess from 'svelte-preprocess'; 2 | import adapter from '@sveltejs/adapter-auto'; 3 | import path from 'path'; 4 | 5 | /** @type {import('@sveltejs/kit').Config} */ 6 | const config = { 7 | kit: { 8 | adapter: adapter(), 9 | 10 | vite: { 11 | css: { 12 | preprocessorOptions: { 13 | scss: { 14 | additionalData: '@use "src/variables.scss" as *;' 15 | } 16 | } 17 | }, 18 | resolve: { 19 | alias: { 20 | '$stores': path.resolve('./src/stores'), 21 | '$utils': path.resolve('./src/utils') 22 | } 23 | } 24 | } 25 | }, 26 | 27 | preprocess: [ 28 | preprocess({ 29 | scss: { 30 | prependData: '@use "src/variables.scss" as *;' 31 | } 32 | }) 33 | ] 34 | }; 35 | 36 | export default config; 37 | -------------------------------------------------------------------------------- /src/lib/utils/TextAlignEmbed.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | {#if (disabled)} 7 | 8 | {:else} 9 |
10 | 11 |
12 | {/if} 13 | 14 | -------------------------------------------------------------------------------- /src/lib/utils/Sizer.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
18 | 19 |
20 | 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "speedskating", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "dev": "svelte-kit dev", 6 | "build": "svelte-kit build", 7 | "package": "svelte-kit package", 8 | "preview": "svelte-kit preview", 9 | "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", 10 | "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. ." 11 | }, 12 | "devDependencies": { 13 | "@sveltejs/adapter-auto": "next", 14 | "@sveltejs/kit": "next", 15 | "d3": "^7.3.0", 16 | "eslint": "^7.32.0", 17 | "eslint-config-prettier": "^8.3.0", 18 | "eslint-plugin-svelte3": "^3.2.1", 19 | "lodash-es": "^4.17.21", 20 | "prettier": "^2.4.1", 21 | "prettier-plugin-svelte": "^2.4.0", 22 | "regl": "^2.1.0", 23 | "sass": "^1.46.0", 24 | "svelte": "^3.44.0", 25 | "svelte-preprocess": "^4.10.1", 26 | "svelte-watch-resize": "^1.0.3" 27 | }, 28 | "type": "module" 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/controls/TimeDisplay.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 | 11 | {formatValue(clock, 'racetime')} 12 | 13 | {#if (speed && speed > 1)} 14 | 15 | ({Math.round(speed * 10) / 10}x) 16 | 17 | {/if} 18 |
19 | 20 | -------------------------------------------------------------------------------- /src/utils/format.js: -------------------------------------------------------------------------------- 1 | import { timeParse, timeFormat } from 'd3'; 2 | 3 | export const NA_STRING = '—'; 4 | export const CURRENCY_FORMAT = '$,.0f'; 5 | export const PERCENT_FORMAT = '.1%'; 6 | export const INTEGER_FORMAT = ',.0f'; 7 | export const FLOAT_FORMAT = ',.1f'; 8 | 9 | const timeFormatRace = () => (value) => { 10 | const minuteValue = value / 1000 / 60; 11 | const minutes = Math.floor(minuteValue); 12 | const seconds = ((minuteValue % 1) * 60).toFixed(2); 13 | const secondsStr = `${seconds}`.replace('.', ',') 14 | return `${minutes}:${secondsStr.padStart(5, '0')}`; 15 | }; 16 | 17 | const indicatorFormats = { 18 | 'millisecond': timeFormat('%L'), 19 | 'racetime': timeFormatRace() 20 | }; 21 | 22 | export const formatValue = (value, indicatorId) => { 23 | const formatter = indicatorFormats[indicatorId]; 24 | return formatter(value); 25 | }; 26 | 27 | export const parseSplitTime = (value) => { 28 | if (!/:/g.test(value)) value = `0:${value}`; 29 | const [ minutes, seconds ] = value.split(':'); 30 | return Math.round((parseFloat(minutes) * 60 + parseFloat(seconds)) * 1000); 31 | }; -------------------------------------------------------------------------------- /src/lib/overlay/NumberSwitcher.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 16 | {#key i} 17 | 22 | {i} 23 | 24 | {/key} 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/utils/actions.js: -------------------------------------------------------------------------------- 1 | const setCssVariables = (node, variables) => { 2 | for (const name in variables) { 3 | if (variables[name]) { 4 | node.style.setProperty(`--${name}`, variables[name]); 5 | } 6 | } 7 | }; 8 | 9 | export const css = (node, variables) => { 10 | setCssVariables(node, variables); 11 | 12 | return { 13 | update(variables) { 14 | setCssVariables(node, variables); 15 | } 16 | }; 17 | }; 18 | 19 | let intersectionObserver; 20 | 21 | const ensureIntersectionObserver = () => { 22 | if (intersectionObserver) return; 23 | 24 | intersectionObserver = new IntersectionObserver( 25 | (entries) => { 26 | entries.forEach(entry => { 27 | const eventName = entry.isIntersecting ? 'enterViewport' : 'exitViewport'; 28 | entry.target.dispatchEvent(new CustomEvent(eventName)); 29 | }); 30 | } 31 | ); 32 | }; 33 | 34 | export const viewport = (element) => { 35 | ensureIntersectionObserver(); 36 | intersectionObserver.observe(element); 37 | return { 38 | destroy() { 39 | intersectionObserver.unobserve(element); 40 | } 41 | } 42 | }; -------------------------------------------------------------------------------- /src/lib/overlay/Countdown.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
18 |
19 | 22 |
23 |
24 | 25 | -------------------------------------------------------------------------------- /src/utils/math.js: -------------------------------------------------------------------------------- 1 | import { scaleLinear, scalePow } from 'd3'; 2 | 3 | export const getPositionScale = ({ times, sectors, startingExponent }) => { 4 | const powScale = scalePow() 5 | .exponent(startingExponent) 6 | .domain(times.slice(0, 2)) 7 | .range(sectors.slice(0, 2)) 8 | .clamp(true); 9 | 10 | const linearScale = scaleLinear() 11 | .domain(times.slice(1)) 12 | .range(sectors.slice(1)) 13 | .clamp(true); 14 | 15 | const fn = (value) => { 16 | if (value < times[1]) return powScale(value); 17 | return linearScale(value); 18 | } 19 | 20 | fn.domain = () => [times[0], times.slice(-1)[0]]; 21 | fn.range = () => [sectors[0], sectors.slice(-1)[0]]; 22 | 23 | return fn; 24 | }; 25 | 26 | export const getNormalOffsetCoords = ( 27 | { x: x0, y: y0 }, 28 | { x: x1, y: y1 }, 29 | { x: x2, y: y2 }, 30 | offset = 0 31 | ) => { 32 | const normal = { 33 | x: y2 - y1, 34 | y: -x2 + x1 35 | }; 36 | 37 | return { 38 | x: x0 + normal.x * offset, 39 | y: y0 + normal.y * offset 40 | }; 41 | }; 42 | 43 | export const getRange = (start, end, step = 1) => { 44 | const len = Math.floor((end - start) / step) + 1; 45 | return Array(len).fill().map((_, i) => start + (i * step)); 46 | }; -------------------------------------------------------------------------------- /src/stores/clock.js: -------------------------------------------------------------------------------- 1 | import { writable, derived } from 'svelte/store'; 2 | 3 | import { animationDetails } from '$stores/data'; 4 | 5 | export const clock = writable(0); 6 | export const raceClock = writable(0); 7 | 8 | export const sectorCompletes = (() => { 9 | const { set, update, subscribe } = writable([]); 10 | let stdRemoveInterval = 2000; 11 | let tid; 12 | 13 | const reset = () => { 14 | clearTimeout(tid); 15 | set([]); 16 | }; 17 | 18 | const remove = () => { 19 | update(values => values.slice(1)); 20 | }; 21 | 22 | const add = (obj, { oneOnly = false, removeInterval = stdRemoveInterval } = {}) => { 23 | if (oneOnly) { 24 | set([obj]); 25 | } else { 26 | update(values => [...values, obj]); 27 | } 28 | tid = setTimeout(remove, removeInterval); 29 | }; 30 | 31 | return { 32 | add, 33 | reset, 34 | subscribe 35 | }; 36 | })(); 37 | 38 | export const clockStep = derived(animationDetails, $animationDetails => { 39 | const { speed, step } = $animationDetails; 40 | return speed * step; 41 | }); 42 | 43 | export const realClockStep = derived(animationDetails, $animationDetails => { 44 | const { speed, step } = $animationDetails; 45 | return speed * Math.max(16.66, step); 46 | }); -------------------------------------------------------------------------------- /src/lib/utils/SizeDetector.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | 39 |
inView = true} 44 | on:exitViewport={() => {}} 45 | > 46 | 47 |
48 | 49 | -------------------------------------------------------------------------------- /src/lib/header/Header.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 |
14 |

15 | {title} 16 |

17 | {#if (subtitle)} 18 |

19 | {@html subtitle} 20 |

21 | {/if} 22 |
23 |
24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Speedskating 2 | 3 | > a DER SPIEGEL Olympia widget 4 | 5 | Screenshot of the speedskating widget 6 | 7 | An animation widget to show olympic speedskating runs. Built with [Svelte](https://svelte.dev), [D3](https://d3js.org) and [regl](http://regl.party). 8 | 9 | Watch an example video [here](https://twitter.com/h_i_g_s_c_h/status/1490722093004214281?s=20&t=FJtQAXIF6Kp0vrFUG_X4tw). 10 | 11 | 12 | ## Run locally 13 | 14 | ``` 15 | git clone https://github.com/spiegelgraphics/speedskating.git 16 | cd speedskating 17 | npm install 18 | npm run dev 19 | ``` 20 | 21 | Data for the speedskating runs is imported via `config.json`. An example can be found in the `static` folder. 22 | More (data-independent) parameters can be set in the store `params.js`. 23 | 24 | 25 | ## Examples 26 | 27 | [Twitter video 1](https://twitter.com/h_i_g_s_c_h/status/1490722093004214281?s=20&t=FJtQAXIF6Kp0vrFUG_X4tw) 28 | 29 | [Twitter video 2](https://twitter.com/h_i_g_s_c_h/status/1490965786986676224?s=20&t=FJtQAXIF6Kp0vrFUG_X4tw) 30 | 31 | [Twitter video 3](https://twitter.com/h_i_g_s_c_h/status/1491828448620425220?s=20&t=FJtQAXIF6Kp0vrFUG_X4tw) 32 | 33 | [Pechstein vs. herself (S+) article](https://www.spiegel.de/sport/wintersport/claudia-pechsteins-lauf-gegen-sich-selbst-a-7d20bc82-8494-4e16-b366-9d3484e6ddcc) 34 | 35 | 36 | ## Built by 37 | 38 | the [DER SPIEGEL](https://www.spiegel.de) graphics department, 2022. 39 | 40 | The application was slightly changed compared to its original to be able to run it outside of a dedicated CMS. 41 | 42 | ## License 43 | 44 | Apache License Version 2.0 -------------------------------------------------------------------------------- /src/lib/controls/PlayButton.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 | 34 |
35 | 36 | -------------------------------------------------------------------------------- /src/variables.scss: -------------------------------------------------------------------------------- 1 | // Colors 2 | $darkGrey: #333; 3 | $lighterGrey: #999; 4 | $lightGrey: #ccc; 5 | 6 | $basic-black: #000000 !default; // rgb(000, 000, 000) 7 | $basic-white: #ffffff !default; // rgb(255, 255, 255) 8 | $basic-black-dark: #1F1E1C !default; // rgb(31, 30, 28); 9 | $basic-white-dark: #F1EFED !default; // rgb(241, 239, 237); 10 | $primary-base-dark: #D03D12; 11 | 12 | $shade-darkest: #2F2D2B !default; // rgb(047, 045, 043) 13 | $shade-darker: #5C5A58 !default; // rgb(092, 090, 088) 14 | $shade-dark: #807E7C !default; // rgb(128, 126, 124) 15 | $shade-base: #989694 !default; // rgb(152, 150, 148) 16 | $shade-light: #BBB9B7 !default; // rgb(187, 185, 183) 17 | $shade-lighter: #DDDBD9 !default; // rgb(221, 219, 217) 18 | $shade-lightest: #F1EFED !default; // rgb(241, 239, 237) 19 | $primary-darker: #811D00 !default; // rgb(129, 029, 000) 20 | $primary-dark: #C12B00 !default; // rgb(193, 043, 000) 21 | $primary-base: #E64415 !default; // rgb(230, 068, 021) 22 | $primary-lighter: #EEC2B5 !default; // rgb(238, 194, 181) 23 | $secondary-1-lighter: #B7DED1 !default; // rgb(183, 222, 209) 24 | $secondary-2-darker: #1A3A4A !default; // rgb(026, 058, 074) 25 | $secondary-2-dark: #235973 !default; // rgb(035, 089, 115) 26 | $secondary-2-lightest: #E9F1F5 !default; // rgb(233, 241, 245) 27 | $special-positive: #3CC83C !default; // rgb(060, 200, 060) 28 | $special-attention: #FFE646 !default; // rgb(255, 230, 070) 29 | $special-negative: #DC3E0F !default; // rgb(220, 062, 015) 30 | $special-bento: #BAF5E7 !default; // rgb(186, 245, 231) 31 | 32 | $shade-darker-dark: #DDDBD9; 33 | $shade-dark-dark: #BBB9B7; 34 | $shade-base-dark: #BBB9B7; 35 | $shade-darker: #5C5A58; 36 | $shade-lighter: #DDDBD9; 37 | $shade-lightest: #F1EFED; -------------------------------------------------------------------------------- /src/lib/controls/ProgressBar.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 |
43 |
49 |
50 | 51 | -------------------------------------------------------------------------------- /src/lib/overlay/Overlay.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | {#if (show)} 25 |
29 | {#if (isAfter)} 30 |
31 | {/if} 32 | 35 | {#if (isBefore)} 36 | 41 | {:else if (isAfter)} 42 | 46 | {/if} 47 | 48 |
49 | {/if} 50 | 51 | -------------------------------------------------------------------------------- /src/lib/animation/Athlete.svelte: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/animation/Animation.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 |
28 | 37 | 41 | {#if ($animationDetails.type === 'speedskating')} 42 | 48 | {/if} 49 | {#if (tracksReady && !showsResults)} 50 | 58 | {/if} 59 | 60 | 68 |
69 | 70 | -------------------------------------------------------------------------------- /src/lib/animation/SpeedskatingArena.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/controls/Controls.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 | 60 |
61 | 64 | 68 | 74 |
75 |
76 | 77 | -------------------------------------------------------------------------------- /src/routes/index.svelte: -------------------------------------------------------------------------------- 1 | 30 | 31 |
32 | 35 | 39 |
47 |
52 | 55 | 60 | 64 |
65 |
66 |
67 |
68 | 69 | -------------------------------------------------------------------------------- /src/stores/data.js: -------------------------------------------------------------------------------- 1 | import { derived } from 'svelte/store'; 2 | import { json, autoType } from 'd3'; 3 | import { maxBy } from 'lodash-es'; 4 | 5 | import { parseSplitTime } from '$utils/format'; 6 | 7 | import { params } from '$stores/params'; 8 | 9 | const config = derived(params, async ($params, set) => { 10 | const { config_path } = $params; 11 | try { 12 | const data = await json(config_path, autoType); 13 | set(data); 14 | } catch { 15 | set({}); 16 | } 17 | }, {}); 18 | 19 | export const race = derived(config, $config => { 20 | const { race = {} } = $config; 21 | return race; 22 | }); 23 | 24 | export const setup = derived(race, $race => { 25 | const { setup = {} } = $race; 26 | return setup; 27 | }); 28 | 29 | export const results = derived(race, $race => { 30 | const { results = [] } = $race; 31 | return results.map(result => { 32 | const { times, finish_time } = result; 33 | const cumulativeTimes = [0, ...times.map(d => parseSplitTime(d))]; 34 | const finishTimeMs = parseSplitTime(finish_time); 35 | return { 36 | ...result, 37 | times, 38 | cumulativeTimes, 39 | finish_time: finish_time.replace('.', ','), 40 | finishTimeMs 41 | }; 42 | }); 43 | }); 44 | 45 | export const athletes = derived([config, params], ([$config, $params]) => { 46 | const { athletes = [] } = $config; 47 | const { flag_server } = $params; 48 | const flaggedAthletes = athletes.map(athlete => { 49 | const flagPath = `${flag_server}${athlete.iso}.svg`; 50 | return { 51 | ...athlete, 52 | flagPath 53 | } 54 | }) 55 | return flaggedAthletes; 56 | }); 57 | 58 | export const trackData = derived(config, $config => { 59 | const { race = {}, tracks = {} } = $config; 60 | if (!race || !tracks) return {}; 61 | const { setup: { type } = {} } = race; 62 | const track = tracks[type]; 63 | const { parent_svg = {} } = track || {}; 64 | return { 65 | ...track, 66 | aspectRatio: parent_svg.width / parent_svg.height 67 | }; 68 | }); 69 | 70 | export const animationDetails = derived([results, setup], ([$results, $setup]) => { 71 | const { speed, countdown, starting, ending } = $setup; 72 | const { finishTimeMs: raceLength } = maxBy($results, 'finishTimeMs') || {}; 73 | return { 74 | ...$setup, 75 | countdown: countdown * speed, 76 | starting: starting * speed, 77 | ending: ending * speed, 78 | raceLength 79 | }; 80 | }); 81 | 82 | export const athleteResults = derived([athletes, results], ([$athletes, $results]) => { 83 | const combined = $results.map((result, id) => { 84 | const { athlete_id } = result; 85 | const athlete = $athletes.find(d => d.athlete_id === athlete_id); 86 | return { 87 | id, 88 | ...athlete, 89 | ...result 90 | }; 91 | }); 92 | 93 | return combined; 94 | }); -------------------------------------------------------------------------------- /src/lib/animation/IntermediateDisplay.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 |
    33 | {#each renderedData as { id, time, display, color} (id)} 34 |
  • 39 | 40 | {time} 41 | 42 | 43 | {display} 44 | 45 |
  • 46 | {/each} 47 |
48 | 49 | -------------------------------------------------------------------------------- /src/lib/animation/Athletes.svelte: -------------------------------------------------------------------------------- 1 | 86 | 87 | 91 | {#each athletes as data, i} 92 | 105 | {/each} 106 | 107 | -------------------------------------------------------------------------------- /src/lib/overlay/Results.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 |
22 | {#if (title)} 23 |

24 | {title} 25 |

26 | {/if} 27 |
    28 | {#each orderBy(results, 'finishTimeMs') as { id, rank, first_name, last_name, finish_time, year, highlight, medal, background_color, country }, i (id)} 29 |
  • 33 | {#if (show_medals)} 34 | 35 | {/if} 36 | 37 | {rank}. 38 | 39 | 40 | {finish_time} 41 | 42 | 43 | {composeName(first_name, last_name, country)} 44 | 45 | {#if (show_year)} 46 | 47 | {year} 48 | 49 | {/if} 50 |
  • 51 | {/each} 52 |
53 |
54 | 55 | -------------------------------------------------------------------------------- /src/utils/webgl.js: -------------------------------------------------------------------------------- 1 | import { color } from 'd3'; 2 | 3 | export const initCanvas = (canvas, width, height) => { 4 | const devicePixelRatio = window.devicePixelRatio || 1; 5 | 6 | canvas.width = devicePixelRatio * width; 7 | canvas.height = devicePixelRatio * height; 8 | 9 | canvas.style.width = `${width}px`; 10 | canvas.style.height = `${height}px`; 11 | }; 12 | 13 | export const scaleCanvas = (canvas, ctx, width, height) => { 14 | const devicePixelRatio = window.devicePixelRatio || 1; 15 | const backingStoreRatio = 16 | ctx.webkitBackingStorePixelRatio || 17 | ctx.mozBackingStorePixelRatio || 18 | ctx.msBackingStorePixelRatio || 19 | ctx.oBackingStorePixelRatio || 20 | ctx.backingStorePixelRatio || 21 | 1; 22 | 23 | const ratio = devicePixelRatio / backingStoreRatio; 24 | 25 | if (devicePixelRatio !== backingStoreRatio) { 26 | canvas.width = width * ratio; 27 | canvas.height = height * ratio; 28 | canvas.style.width = width + 'px'; 29 | canvas.style.height = height + 'px'; 30 | } else { 31 | canvas.width = width; 32 | canvas.height = height; 33 | canvas.style.width = ''; 34 | canvas.style.height = ''; 35 | } 36 | 37 | ctx.scale(ratio, ratio); 38 | }; 39 | 40 | export const getWebGLColor = (c) => { 41 | const webglColor = Object.values(color(c).rgb()) 42 | .slice(0, 3) 43 | .map(d => d / 255); 44 | return webglColor; 45 | }; 46 | 47 | export const generateCartesianToWebGL = (width, height) => ([ x, y ]) => { 48 | return [ 49 | 2 * ((x / width) - 0.5), 50 | -2 * ((y / height) - 0.5) 51 | ]; 52 | }; 53 | 54 | export const createDrawDots = (regl) => { 55 | return regl({ 56 | frag: ` 57 | precision highp float; 58 | 59 | varying vec3 fragColor; 60 | varying float fragOpacity; 61 | 62 | void main() { 63 | float point_dist = length(gl_PointCoord * 2. - 1.); 64 | 65 | if(point_dist > 1.0) discard; 66 | float alpha = fragOpacity; 67 | 68 | gl_FragColor = vec4(1.0 - alpha * (1.0 - fragColor), 1); 69 | } 70 | `, 71 | vert: ` 72 | precision highp float; 73 | 74 | attribute vec2 position; 75 | attribute float size; 76 | attribute vec3 color; 77 | attribute float opacity; 78 | 79 | varying vec3 fragColor; 80 | varying float fragOpacity; 81 | 82 | void main() { 83 | fragColor = color; 84 | fragOpacity = opacity; 85 | gl_PointSize = size; 86 | gl_Position = vec4(position, 0.0, 1.0); 87 | } 88 | `, 89 | depth: { 90 | enable: false, 91 | mask: false 92 | }, 93 | attributes: { 94 | position: { 95 | buffer: (_, props) => { 96 | return props.coords 97 | } 98 | }, 99 | size: { 100 | buffer: (_, props) => { 101 | return props.sizes 102 | } 103 | }, 104 | color: { 105 | buffer: (_, props) => { 106 | return props.colors 107 | } 108 | }, 109 | opacity: { 110 | buffer: (_, props) => { 111 | return props.opacities 112 | } 113 | } 114 | }, 115 | blend: { 116 | enable: true, 117 | func: { 118 | srcRGB: 'src alpha', 119 | srcAlpha: 'src alpha', 120 | dstRGB: 'one minus src alpha', 121 | dstAlpha: 'one minus src alpha', 122 | } 123 | }, 124 | count: (_, props) => props.n, 125 | primitive: 'points' 126 | }); 127 | }; -------------------------------------------------------------------------------- /src/lib/animation/Track.svelte: -------------------------------------------------------------------------------- 1 | 147 | 148 | 149 | 150 |
153 | 158 | 164 | 167 | 172 | 173 | 174 | 175 | 176 |
177 | 178 | -------------------------------------------------------------------------------- /static/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "race": { 3 | "setup": { 4 | "type": "speedskating", 5 | "name": "Claudia Pechstein 3000m", 6 | "date": "2022-02-05", 7 | "place": "Beijing and others", 8 | "title": "Running after her shadows", 9 | "subtitle": "3000m speed skating: Claudia Pechstein in 2022 vs. her previous self.", 10 | "length": 3000, 11 | "composition": "6,7,8,9,10,0,1,2,3,4,5,6,7,8,9,10,0,1,2,3,4,5,6,7,8,9,10,0,1,2,3,4,5,6,7,8,9,10,0,1,2,3,4,5,6,7,8,9,10,0,1,2,3,4,5,6,7,8,9,10,0,1,2,3,4,5,6,7,8,9,10,0,1,2,3,4,5,6,7,8,9,10", 12 | "sectors": [0.0, 0.0667, 0.2, 0.3333, 0.4667, 0.6, 0.7333, 0.8667, 1.0], 13 | "num_paths": 7, 14 | "resolution": 5000, 15 | "trail_length": 30, 16 | "countdown": 3000, 17 | "starting": 0, 18 | "ending": 0, 19 | "step": 5, 20 | "starting_exponent": 1.4, 21 | "offset_function_type": "sinewave", 22 | "track_offset_factor": 0.8, 23 | "speed": 10, 24 | "result": { 25 | "show_year": true, 26 | "show_country": false 27 | }, 28 | "sources": "Sources: Sports Reference, ISU" 29 | }, 30 | "results": [ 31 | { 32 | "athlete_id": 0, 33 | "year": 2022, 34 | "times": ["21.04", "53.35", "1:26.36", "1:59.91", "2:34.03", "3:08.31", "3:42.90", "4:17.16"], 35 | "finish_time": "4:17.16", 36 | "opacity": 0.7, 37 | "background_color": "#D03D12", 38 | "trail_color": "#C4DDEF", 39 | "size": 16, 40 | "show_flag": false, 41 | "rank": 6, 42 | "medal": null 43 | }, 44 | { 45 | "athlete_id": 0, 46 | "year": 2002, 47 | "times": ["19.45", "49.93", "1:21.00", "1:52.30", "2:23.38", "2:54.57", "3:25.86", "3:57.70"], 48 | "finish_time": "3:57.70", 49 | "opacity": 0.7, 50 | "background_color": "#0096C7", 51 | "trail_color": "#C4DDEF", 52 | "size": 16, 53 | "show_flag": false, 54 | "rank": 1, 55 | "medal": "#D6AF36" 56 | }, 57 | { 58 | "athlete_id": 0, 59 | "year": 2018, 60 | "times": ["20.73", "52.02", "1:23.45", "1:55.07", "2:26.88", "2:58.96", "3:31.53", "4:04.49"], 61 | "finish_time": "4:04.49", 62 | "opacity": 0.7, 63 | "background_color": "#0096C7", 64 | "trail_color": "#C4DDEF", 65 | "size": 16, 66 | "show_flag": false, 67 | "rank": 2, 68 | "medal": null 69 | }, 70 | { 71 | "athlete_id": 0, 72 | "year": 2014, 73 | "times": ["20.21", "51.19", "1:22.54", "1:54.41", "2:26.30", "2:58.87", "3:32.00", "4:05.26"], 74 | "finish_time": "4:05.26", 75 | "opacity": 0.7, 76 | "background_color": "#0096C7", 77 | "trail_color": "#C4DDEF", 78 | "size": 16, 79 | "show_flag": false, 80 | "rank": 3, 81 | "medal": null 82 | }, 83 | 84 | { 85 | "athlete_id": 0, 86 | "year": 2006, 87 | "times": ["19.53", "50.21", "1:21.68", "1:53.55", "2:25.84", "2:58.64", "3:31.66", "4:05.54"], 88 | "finish_time": "4:05.54", 89 | "opacity": 0.7, 90 | "background_color": "#0096C7", 91 | "trail_color": "#C4DDEF", 92 | "size": 16, 93 | "show_flag": false, 94 | "rank": 4, 95 | "medal": null 96 | }, 97 | { 98 | "athlete_id": 0, 99 | "year": 1998, 100 | "times": ["20.11", "51.41", "1:22.79", "1:54.30", "2:26.29", "2:59.12", "3:33.21", "4:08.47"], 101 | "finish_time": "4:08.47", 102 | "opacity": 0.7, 103 | "background_color": "#0096C7", 104 | "trail_color": "#C4DDEF", 105 | "size": 16, 106 | "show_flag": false, 107 | "rank": 5, 108 | "medal": "#A7A7AD" 109 | }, 110 | { 111 | "athlete_id": 0, 112 | "year": 1994, 113 | "times": ["20.23", "52.53", "1:25.80", "1:59.51", "2:33.63", "3:08.05", "3:43.09", "4:18.34"], 114 | "finish_time": "4:18.34", 115 | "opacity": 0.7, 116 | "background_color": "#0096C7", 117 | "trail_color": "#C4DDEF", 118 | "size": 16, 119 | "show_flag": false, 120 | "rank": 7, 121 | "medal": "#fcb59f" 122 | } 123 | ] 124 | }, 125 | "athletes": [ 126 | { 127 | "athlete_id": 0, 128 | "first_name": "Claudia", 129 | "last_name": "Pechstein", 130 | "age": 49, 131 | "country": "Deutschland", 132 | "iso": "de" 133 | } 134 | ], 135 | "tracks": { 136 | "speedskating": { 137 | "parent_svg": { 138 | "width": 2586, 139 | "height": 1048 140 | }, 141 | "paths": [ 142 | { 143 | "id": 0, 144 | "path": "M1878 922.5H2037" 145 | }, 146 | { 147 | "id": 1, 148 | "path": "M2037,922.5C2257.09,922.5 2435.5,744.085 2435.5,524" 149 | }, 150 | { 151 | "id": 2, 152 | "path": "M2435.5 524C2435.5 303.415 2257.09 125 2037 125" 153 | }, 154 | { 155 | "id": 3, 156 | "path": "M2037 125H1933" 157 | }, 158 | { 159 | "id": 4, 160 | "path": "M1933 125H1188" 161 | }, 162 | { 163 | "id": 5, 164 | "path": "M1188 125H604" 165 | }, 166 | { 167 | "id": 6, 168 | "path": "M604 125H549" 169 | }, 170 | { 171 | "id": 7, 172 | "path": "M549,125C329.415,125 151,303.415 151,524" 173 | }, 174 | { 175 | "id": 8, 176 | "path": "M151,524C151,744.085 329.415,922.5 549,922.5" 177 | }, 178 | { 179 | "id": 9, 180 | "path": "M549 922.5H1293" 181 | }, 182 | { 183 | "id": 10, 184 | "path": "M1293 922.5H1878" 185 | } 186 | ], 187 | "intermediate_display": { 188 | "show_type": ["last_name", "year"] 189 | } 190 | } 191 | } 192 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------