├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── global.css ├── index.html ├── laureates.csv ├── preview.png └── steps.json ├── rollup.config.js ├── src ├── App.svelte ├── components │ ├── Canvas.svelte │ ├── ClusterLabels.svelte │ ├── HistogramAxis.svelte │ ├── HoverLabel.svelte │ ├── HoverLabels.svelte │ ├── Laureate.svelte │ ├── Laureates.svelte │ ├── Legend.svelte │ ├── ScrollLabel.svelte │ ├── ScrollyVisualization.svelte │ └── Sources.svelte ├── main.js ├── stores │ ├── colors.js │ ├── data.js │ ├── devices.js │ ├── dimensions.js │ └── steps.js └── utils │ ├── canvas.js │ ├── colors.js │ ├── dictionary.js │ ├── format.js │ └── layout.js └── svelte.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /public/build/ 3 | 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nobel-Laureates 2 | 3 | > a DER SPIEGEL scrollytelling 4 | 5 | Screenshot of the Nobel laureates scrollytelling 6 | 7 | A scrollytelling to highlight all Nobel laureates since 1901. Built with [Svelte](https://svelte.dev) and [D3](https://d3js.org). 8 | 9 | Live [here](https://www.spiegel.de/wissenschaft/zirkel-der-genies-a-90c50289-30ac-4a4b-bc49-348676ce6687) (S+) or on your local machine (see below). 10 | 11 | `steps.json` contains the configuration of the scrollytelling chapters. `laureates.csv` holds the individual Nobel laureate data from the [The Nobel Foundation](https://www.nobelprize.org/about/developer-zone-2/). 12 | 13 | ## Run locally 14 | 15 | ``` 16 | git clone https://github.com/spiegelgraphics/nobel-laureates.git 17 | cd nobel-laureates 18 | npm install 19 | npm run dev 20 | ``` 21 | 22 | 23 | ## Built by 24 | 25 | the [DER SPIEGEL](https://www.spiegel.de) graphics department, 2022. 26 | 27 | The application was slightly changed compared to its original to be able to run it outside of a dedicated CMS. 28 | 29 | 30 | ## License 31 | 32 | Apache License Version 2.0 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nobel-laureates", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "rollup -c", 7 | "dev": "rollup -c -w", 8 | "start": "sirv public" 9 | }, 10 | "devDependencies": { 11 | "@rollup/plugin-commonjs": "^17.0.0", 12 | "@rollup/plugin-node-resolve": "^11.0.0", 13 | "@sveltejs/svelte-scroller": "^2.0.7", 14 | "d3": "^7.4.4", 15 | "lodash-es": "^4.17.21", 16 | "rollup": "^2.41.4", 17 | "rollup-plugin-css-only": "^3.1.0", 18 | "rollup-plugin-livereload": "^2.0.0", 19 | "rollup-plugin-svelte": "^7.0.0", 20 | "rollup-plugin-terser": "^7.0.2", 21 | "svelte": "^3.0.0" 22 | }, 23 | "dependencies": { 24 | "node-sass": "^4.14.1", 25 | "sirv-cli": "^2.0.2", 26 | "svelte-preprocess": "^4.6.9" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /public/global.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | html, body { 8 | width: 100%; 9 | height: 100%; 10 | font-family: Arial, sans-serif; 11 | font-size: 16px; 12 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Nobel laureates 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spiegelgraphics/nobel-laureates/f7a66cac1065582225ca7c23b790fb93edf76668/public/preview.png -------------------------------------------------------------------------------- /public/steps.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "showTick": true, 4 | "largeTick": true, 5 | "cluster_by": "prize_category", 6 | "inner_layout": { 7 | "type": "phyllotaxis" 8 | }, 9 | "outer_layout": { 10 | "type": "clusterForce" 11 | }, 12 | "highlightIds": [], 13 | "exclusiveIds": [997, 998, 999, 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009], 14 | "description": { 15 | "title": "Klimawandel, Kolonialismus, Meinungsfreiheit", 16 | "body": "Auch zwei Deutsche sind unter den aktuellen Gewinnern. Den Nobelpreis für Chemie bekamen Benjamin List und der in Schottland geborene David W.C. MacMillan." 17 | }, 18 | "showCategoryLabels": false 19 | }, 20 | { 21 | "showTick": true, 22 | "largeTick": false, 23 | "cluster_by": "prize_category", 24 | "inner_layout": { 25 | "type": "phyllotaxis" 26 | }, 27 | "outer_layout": { 28 | "type": "clusterForce" 29 | }, 30 | "highlightIds": [1002, 1003], 31 | "exclusiveIds": [997, 998, 999, 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009], 32 | "description": { 33 | "title": "Klimawandel, Kolonialismus, Meinungsfreiheit", 34 | "body": "Die Forscher fanden heraus, dass auch kleine organische Moleküle chemische Reaktionen vermitteln." 35 | }, 36 | "showCategoryLabels": false 37 | }, 38 | { 39 | "showTick": true, 40 | "largeTick": false, 41 | "cluster_by": "prize_category", 42 | "inner_layout": { 43 | "type": "phyllotaxis" 44 | }, 45 | "outer_layout": { 46 | "type": "clusterForce" 47 | }, 48 | "highlightIds": [999, 1000, 1001], 49 | "exclusiveIds": [997, 998, 999, 1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009], 50 | "description": { 51 | "title": "Klimawandel, Kolonialismus, Meinungsfreiheit", 52 | "body": "In der Kategorie Physik ging der Preis an den Deutschen Klaus Hasselmann sowie dessen Kollegen Giorgio Parisi und Syukuro Manabe. Hasselmann entwickelte unter anderem ein Modell, das zeigt, wie schnelle Temperaturschwankungen der Atmosphäre die Ozeantemperatur langfristig beeinflussen." 53 | }, 54 | "showCategoryLabels": false 55 | }, 56 | { 57 | "showTick": true, 58 | "largeTick": true, 59 | "cluster_by": "uniform", 60 | "inner_layout": { 61 | "type": "phyllotaxis" 62 | }, 63 | "outer_layout": { 64 | "type": "clusterForce" 65 | }, 66 | "highlightIds": [], 67 | "exclusiveIds": [], 68 | "description": { 69 | "title": "Geballte Genies", 70 | "body": "Seit 1901 erhielten insgesamt 947 Menschen die Ehrung. Sie kann auch an Organisationen verliehen werden, was in 28 Fällen in Form eines Friedensnobelpreises geschah. In manchen Jahren fiel die Preisvergabe aus, etwa in den Weltkriegsjahren 1914-18 und 1939-1945." 71 | }, 72 | "showCategoryLabels": false 73 | }, 74 | { 75 | "showTick": true, 76 | "largeTick": true, 77 | "cluster_by": "prize_category", 78 | "inner_layout": { 79 | "type": "phyllotaxis" 80 | }, 81 | "outer_layout": { 82 | "type": "clusterForce" 83 | }, 84 | "highlightIds": [], 85 | "exclusiveIds": [], 86 | "description": { 87 | "title": "Führende Medizin", 88 | "body": "Die meisten Preisträger stammen aus dem Bereich Medizin (224), dicht gefolgt von Physik (219), Chemie (186), Literatur (118) und Frieden (109). Erst seit 1968 werden Forscherinnen und Forscher der Wirtschaftswissenschaften (89) geehrt." 89 | }, 90 | "showCategoryLabels": true 91 | }, 92 | { 93 | "showTick": true, 94 | "largeTick": true, 95 | "cluster_by": "prize_category", 96 | "inner_layout": { 97 | "type": "phyllotaxis" 98 | }, 99 | "outer_layout": { 100 | "type": "clusterForce" 101 | }, 102 | "highlightIds": [222, 66, 217, 6], 103 | "exclusiveIds": [], 104 | "description": { 105 | "title": "Zweifach geehrt", 106 | "body": "In seltenen Fällen räumen Forscher doppelt ab. Bei insgesamt vier Wissenschaftlerinnen und Wissenschaftlern ist das in der Vergangenheit der Fall gewesen." 107 | }, 108 | "showCategoryLabels": true 109 | }, 110 | { 111 | "showTick": true, 112 | "largeTick": false, 113 | "cluster_by": "prize_category", 114 | "inner_layout": { 115 | "type": "phyllotaxis" 116 | }, 117 | "outer_layout": { 118 | "type": "clusterForce" 119 | }, 120 | "highlightIds": [6], 121 | "exclusiveIds": [], 122 | "description": { 123 | "title": "Zweifach geehrt", 124 | "body": "Ein besonders berühmtes Beispiel: Marie Curie. Ihr wurde 1903 der Physiknobelpreis für die Pionierleistung auf dem Gebiet der spontanen Radioaktivität verliehen und 1911 der Chemienobelpreis für die Isolierung des Elements Radium." 125 | }, 126 | "showCategoryLabels": true 127 | }, 128 | { 129 | "showTick": true, 130 | "largeTick": true, 131 | "cluster_by": "gender", 132 | "inner_layout": { 133 | "type": "phyllotaxis" 134 | }, 135 | "outer_layout": { 136 | "type": "clusterForce" 137 | }, 138 | "highlightIds": [ 139 | 843, 892, 543, 990, 553, 428, 468, 535, 836, 453, 140 | 962, 817, 230, 782, 846, 835, 869, 506, 991, 983, 141 | 963, 824, 615, 438, 344, 597, 844, 194, 496, 992, 142 | 565, 870, 775, 993, 536, 914, 79, 1005, 6, 904, 540, 143 | 967, 668, 640, 979, 610, 554, 435, 413, 579, 773, 144 | 601, 924, 871, 670, 918, 783, 673, 1005 145 | ], 146 | "exclusiveIds": [], 147 | "description": { 148 | "title": "Unterrepräsentierte Frauen", 149 | "body": "Erst 58 Frauen haben bisher den Nobelpreis erhalten. Auch in diesem Jahr war das Männer-Frauen-Verhältnis unausgewogen." 150 | }, 151 | "showCategoryLabels": true 152 | }, 153 | { 154 | "showTick": true, 155 | "largeTick": false, 156 | "cluster_by": "gender", 157 | "inner_layout": { 158 | "type": "phyllotaxis" 159 | }, 160 | "outer_layout": { 161 | "type": "clusterForce" 162 | }, 163 | "highlightIds": [ 164 | 1005 165 | ], 166 | "exclusiveIds": [], 167 | "description": { 168 | "title": "Unterrepräsentierte Frauen", 169 | "body": "Von den diesjährigen 13 Gewinnern ist nur eine Person weiblich: Die philippinische Journalistin Maria Ressa. Sie teilt sich den Friedensnobelpreis mit dem russischen Journalisten Dmitri Muratow. Beide werden für ihren Kampf für die Pressefreiheit und ihren Einsatz bei der Aufdeckung von Korruption und Machtmissbrauch geehrt." 170 | }, 171 | "showCategoryLabels": true 172 | }, 173 | { 174 | "showTick": true, 175 | "largeTick": true, 176 | "cluster_by": "gender", 177 | "inner_layout": { 178 | "type": "phyllotaxis" 179 | }, 180 | "outer_layout": { 181 | "type": "clusterForce" 182 | }, 183 | "highlightIds": [453], 184 | "exclusiveIds": [], 185 | "description": { 186 | "title": "Made in Germany", 187 | "body": "Eine der wenigen deutschen Preisträgerinnen ist die Biologin und Biochemikerin Christiane Nüsslein-Volhard. Sie bekam die Ehrung für die Erforschung genetischer Kontrolle der frühen Embryonalentwicklung." 188 | }, 189 | "showCategoryLabels": true 190 | }, 191 | { 192 | "showTick": true, 193 | "largeTick": true, 194 | "cluster_by": "uniform", 195 | "inner_layout": { 196 | "type": "histogram" 197 | }, 198 | "outer_layout": { 199 | "type": "none" 200 | }, 201 | "highlightIds": [], 202 | "exclusiveIds": [], 203 | "description": { 204 | "title": "Von jung bis alt", 205 | "body": "Die meisten Nobelpreisträger können auf eine lange Karriere zurückblicken. In der Regel sind die Gewinner älter als dreißig Jahre. Doch es gibt Ausnahmen und gleich zwei davon sind Frauen." 206 | }, 207 | "showCategoryLabels": false 208 | }, 209 | { 210 | "showTick": true, 211 | "largeTick": true, 212 | "cluster_by": "prize_category", 213 | "inner_layout": { 214 | "type": "histogram" 215 | }, 216 | "outer_layout": { 217 | "type": "none" 218 | }, 219 | "highlightIds": [], 220 | "exclusiveIds": [], 221 | "description": { 222 | "title": null, 223 | "body": null 224 | }, 225 | "showCategoryLabels": false 226 | }, 227 | { 228 | "showTick": true, 229 | "largeTick": true, 230 | "cluster_by": "prize_category", 231 | "inner_layout": { 232 | "type": "histogram" 233 | }, 234 | "outer_layout": { 235 | "type": "none" 236 | }, 237 | "highlightIds": [914], 238 | "exclusiveIds": [], 239 | "description": { 240 | "title": "Die jüngsten ...", 241 | "body": "2014 erhielt die damals 17-jährige Malala Yousafzai zusammen mit Kailash Satyarthi den Friedensnobelpreis für ihr Eintreten gegen die Unterdrückung von jungen Menschen und für deren Recht auf Bildung." 242 | }, 243 | "showCategoryLabels": false 244 | }, 245 | { 246 | "showTick": true, 247 | "largeTick": false, 248 | "cluster_by": "prize_category", 249 | "inner_layout": { 250 | "type": "histogram" 251 | }, 252 | "outer_layout": { 253 | "type": "none" 254 | }, 255 | "highlightIds": [967], 256 | "exclusiveIds": [], 257 | "description": { 258 | "title": "Die jüngsten ...", 259 | "body": "Ebenfalls den Friedensnobelpreis bekam die zur Zeit der Ehrung 25-jährige Irakerin Nadia Murad für ihren Einsatz gegen sexuelle Gewalt in Kriegsgebieten." 260 | }, 261 | "showCategoryLabels": false 262 | }, 263 | { 264 | "showTick": true, 265 | "largeTick": true, 266 | "cluster_by": "prize_category", 267 | "inner_layout": { 268 | "type": "histogram" 269 | }, 270 | "outer_layout": { 271 | "type": "none" 272 | }, 273 | "highlightIds": [976], 274 | "exclusiveIds": [], 275 | "description": { 276 | "title": "... und die Ältesten", 277 | "body": "John B. Goodenough war 97 Jahre alt, als er 2019 für die Entwicklung von Lithium-Ionen-Batterien den Chemie-Nobelpreis verliehen bekam. Posthum wird der Nobelpreis seit 1974 nicht mehr verliehen, es sei denn die Anwärter versterben zwischen Nominierung und Preisverleihung." 278 | }, 279 | "showCategoryLabels": false 280 | }, 281 | { 282 | "showTick": true, 283 | "largeTick": true, 284 | "cluster_by": "affiliation_country", 285 | "inner_layout": { 286 | "type": "phyllotaxis" 287 | }, 288 | "outer_layout": { 289 | "type": "clusterForce" 290 | }, 291 | "highlightIds": [], 292 | "exclusiveIds": [], 293 | "description": { 294 | "title": "Gebündelte Brainpower", 295 | "body": "Die Daten aller Nobelpreisgewinner zeigen: Es gibt Staaten die besonders gut darin sind, schlaue Köpfe ins Land zu locken. So lebte ein Großteil zum Zeitpunkt der Verleihung in den USA. Im Ranking folgen Großbritannien und Deutschland." 296 | }, 297 | "showCategoryLabels": true 298 | }, 299 | { 300 | "showTick": true, 301 | "largeTick": true, 302 | "cluster_by": "birth_country", 303 | "inner_layout": { 304 | "type": "phyllotaxis" 305 | }, 306 | "outer_layout": { 307 | "type": "clusterForce" 308 | }, 309 | "highlightIds": [], 310 | "exclusiveIds": [], 311 | "description": { 312 | "title": "Weltweites Wissen", 313 | "body": "Schlüsselt man die Preisträger nach Geburtsland auf ergibt sich hingegen ein diverseres Bild. Doch auch hier ist erkennbar – die meisten stammen aus Europa und den USA." 314 | }, 315 | "showCategoryLabels": true 316 | }, 317 | { 318 | "showTick": true, 319 | "largeTick": true, 320 | "cluster_by": "uniform", 321 | "inner_layout": { 322 | "type": "phyllotaxis" 323 | }, 324 | "outer_layout": { 325 | "type": "clusterForce" 326 | }, 327 | "highlightIds": [ 328 | 199, 164, 185, 26, 304, 111, 1002, 445, 189, 500, 453, 166, 161, 340, 244, 127, 329 | 444, 379, 14, 253, 430, 128, 816, 240, 31, 491, 188, 136, 354, 329, 187, 823, 330 | 272, 647, 184, 530, 727, 617, 216, 156, 130, 80, 134, 30, 945, 270, 24, 976, 331 | 266, 228, 1000, 493, 233, 391, 23, 19, 640, 210, 202, 331, 312, 322, 580, 64, 332 | 941, 989, 176, 271, 799, 297, 578, 76, 394, 571, 793, 886, 602, 62, 361, 38, 333 | 1, 529, 739, 137 334 | ], 335 | "exclusiveIds": [], 336 | "description": { 337 | "title": "Dichter und Denker", 338 | "body": "Von den bisherigen Nobelpreisträgern sind 84 in Deutschland geboren. Berühmte Namen wie Max Planck, Albert Einstein oder Willy Brandt gehören dazu. Schon bei der ersten Preisverleihung 1901 wurde ein Mann geehrt, den heute wohl jedes Kind kennt. Zumindest jedes, das sich schon mal etwas gebrochen hat: Wilhelm Conrad Röntgen." 339 | }, 340 | "showCategoryLabels": true 341 | }, 342 | { 343 | "showTick": true, 344 | "largeTick": true, 345 | "cluster_by": "uniform", 346 | "inner_layout": { 347 | "type": "phyllotaxis" 348 | }, 349 | "outer_layout": { 350 | "type": "clusterForce" 351 | }, 352 | "highlightIds": [ 353 | 199, 164, 185, 26, 304, 111, 1002, 445, 189, 500, 453, 166, 161, 340, 244, 127, 354 | 444, 379, 14, 253, 430, 128, 816, 240, 31, 491, 188, 136, 354, 329, 187, 823, 355 | 272, 647, 184, 530, 727, 617, 216, 156, 130, 80, 134, 30, 945, 270, 24, 976, 356 | 266, 228, 1000, 493, 233, 391, 23, 19, 640, 210, 202, 331, 312, 322, 580, 64, 357 | 941, 989, 176, 271, 799, 297, 578, 76, 394, 571, 793, 886, 602, 62, 361, 38, 358 | 1, 529, 739, 137 359 | ], 360 | "exclusiveIds": [], 361 | "description": { 362 | "title": null, 363 | "body": null 364 | }, 365 | "showCategoryLabels": true 366 | } 367 | ] -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from 'rollup-plugin-svelte'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | import livereload from 'rollup-plugin-livereload'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import css from 'rollup-plugin-css-only'; 7 | import preprocess from 'svelte-preprocess'; 8 | 9 | const production = !process.env.ROLLUP_WATCH; 10 | 11 | function serve() { 12 | let server; 13 | 14 | function toExit() { 15 | if (server) server.kill(0); 16 | } 17 | 18 | return { 19 | writeBundle() { 20 | if (server) return; 21 | server = require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], { 22 | stdio: ['ignore', 'inherit', 'inherit'], 23 | shell: true 24 | }); 25 | 26 | process.on('SIGTERM', toExit); 27 | process.on('exit', toExit); 28 | } 29 | }; 30 | } 31 | 32 | export default { 33 | input: 'src/main.js', 34 | output: { 35 | sourcemap: true, 36 | format: 'iife', 37 | name: 'app', 38 | file: 'public/build/bundle.js' 39 | }, 40 | plugins: [ 41 | svelte({ 42 | compilerOptions: { 43 | // enable run-time checks when not in production 44 | dev: !production 45 | }, 46 | preprocess: preprocess() 47 | }), 48 | // we'll extract any component CSS out into 49 | // a separate file - better for performance 50 | css({ output: 'bundle.css' }), 51 | 52 | // If you have external dependencies installed from 53 | // npm, you'll most likely need these plugins. In 54 | // some cases you'll need additional configuration - 55 | // consult the documentation for details: 56 | // https://github.com/rollup/plugins/tree/master/packages/commonjs 57 | resolve({ 58 | browser: true, 59 | dedupe: ['svelte'] 60 | }), 61 | commonjs(), 62 | 63 | // In dev mode, call `npm run start` once 64 | // the bundle has been generated 65 | !production && serve(), 66 | 67 | // Watch the `public` directory and refresh the 68 | // browser on changes when not in production 69 | !production && livereload('public'), 70 | 71 | // If we're building for production (npm run build 72 | // instead of npm run dev), minify 73 | production && terser() 74 | ], 75 | watch: { 76 | clearScreen: false 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /src/App.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
20 | 21 |
25 | 30 |
34 | 38 |
39 |
40 | {#each $steps as { id, description, largeTick } (id)} 41 | 45 | {/each} 46 |
47 |
48 |
49 | 50 |
51 | 52 | -------------------------------------------------------------------------------- /src/components/Canvas.svelte: -------------------------------------------------------------------------------- 1 | 62 | 63 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/components/ClusterLabels.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | {#if (showLabels)} 22 |
23 | {#each labels as { id, x, y, label } (id)} 24 |
29 | {label} 30 |
31 | {/each} 32 |
33 | {/if} 34 | 35 | -------------------------------------------------------------------------------- /src/components/HistogramAxis.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
18 |
22 |
23 | {#each ticks as tick} 24 |
28 | {tick} 29 |
30 | {/each} 31 |
32 |
33 | {label} 34 |
35 |
36 | 37 | -------------------------------------------------------------------------------- /src/components/HoverLabel.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 |
55 |
56 |

57 | {name} 58 |

59 | {#if (hasCloseButton)} 60 | 65 | {/if} 66 |
67 | {#if dictionary[birth_country]} 68 |

Geburtsort: {dictionary[birth_country]}

69 | {/if} 70 |

71 | {years[0]}: {dictionary[categories[0]]} 72 |

73 |

74 | »{@html motivations[0].trim()}« 75 |

76 | {#if (years[1])} 77 |

78 | {years[1]}: {dictionary[categories[1]]} 79 |

80 | {/if} 81 | {#if (motivations[1])} 82 |

83 | »{@html motivations[1].trim()}« 84 |

85 | {/if} 86 |
87 | 88 | -------------------------------------------------------------------------------- /src/components/HoverLabels.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | {#if (hoverItem)} 13 | hoverItem = null} 19 | /> 20 | {/if} 21 |
22 | 23 | -------------------------------------------------------------------------------- /src/components/Laureate.svelte: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Laureates.svelte: -------------------------------------------------------------------------------- 1 | 50 | 51 |
54 | 61 | {#each renderedData as { id, xTransformed: x, yTransformed: y, radius, withinClusterIndex = 0, outlines, show } (id)} 62 | {#if (show)} 63 | 73 | {/if} 74 | {/each} 75 | 76 |
77 | 78 | -------------------------------------------------------------------------------- /src/components/Legend.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
15 | {#each items as { label, color }} 16 |
17 | 18 | {label} 19 |
20 | {/each} 21 |
22 | 23 | -------------------------------------------------------------------------------- /src/components/ScrollLabel.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
11 |

12 | {@html title} 13 |

14 |

15 | {@html body} 16 |

17 |
18 |
19 | 20 | -------------------------------------------------------------------------------- /src/components/ScrollyVisualization.svelte: -------------------------------------------------------------------------------- 1 | 43 | 44 | {#if (ready)} 45 |
48 |
52 | 61 | {#if (layoutType === 'histogram')} 62 | 66 | {/if} 67 | 71 | 76 |
77 | 80 |
81 | {/if} 82 | 83 | -------------------------------------------------------------------------------- /src/components/Sources.svelte: -------------------------------------------------------------------------------- 1 |
4 |

Quelle: The Nobel Foundation

5 |

6 | Anm. d. Red.: Es sind ausschließlich natürliche Personen gezeigt. 7 | Zur Berechnung des Alters bei Preisvergabe wurde im Falle von mehreren Nobelpreisen pro Person der frühere Nobelpreis herangezogen. 8 | Bei Geburts- und Aufenthaltsland wurde jeweils das im Datensatz erstgenannte verwendet. Es handelt sich außerdem um die heutigen Länderbezeichnungen. 9 | Fehlen bei einer Person die jeweils gezeigten Daten, wurde diese Person von der Darstellung entfernt. 10 |

11 |
12 | 13 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import App from './App.svelte'; 2 | 3 | const app = new App({ 4 | target: document.body 5 | }); 6 | 7 | export default app; -------------------------------------------------------------------------------- /src/stores/colors.js: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | export const laureateColor = writable('#ffffff'); 4 | export const invertedLaureateColor = writable('#E64415'); -------------------------------------------------------------------------------- /src/stores/data.js: -------------------------------------------------------------------------------- 1 | import { readable, derived } from 'svelte/store'; 2 | import { csv, autoType, scaleLinear, extent } from 'd3'; 3 | import { sortBy } from 'lodash-es'; 4 | 5 | import { currentStep } from './steps'; 6 | import { radius, width, canvasHeight as height } from './dimensions'; 7 | import { layoutClusterForce, layoutPhyllotaxis, layoutHistogram } from '../utils/layout'; 8 | import { prizeCategoryColors } from '../utils/colors'; 9 | 10 | const dataPath = 'laureates.csv'; 11 | 12 | const splitProperty = (s) => { 13 | if (s === undefined || s === null) return []; 14 | const sString = s.toString(); 15 | return sString ? sString.split('|') : []; 16 | }; 17 | 18 | export const rawLaureates = readable([], async set => { 19 | const data = await csv(dataPath, autoType); 20 | 21 | const fData = data.map(d => { 22 | return { 23 | ...d, 24 | uniform: splitProperty(d.uniform), 25 | gender: splitProperty(d.gender), 26 | birth_country: splitProperty(d.birth_country), 27 | prize_category: splitProperty(d.prize_category), 28 | prize_motivation: splitProperty(d.prize_motivation), 29 | prize_year: splitProperty(d.prize_year).map(d => +d), 30 | affiliation_country: splitProperty(d.affiliation_country), 31 | this_year: d.this_year === 'TRUE' 32 | }; 33 | }); 34 | 35 | const sortedData = sortBy(fData, item => { 36 | return Object.keys(prizeCategoryColors).indexOf(item.prize_category[0]) 37 | }); 38 | 39 | set(sortedData); 40 | }); 41 | 42 | export const ageScale = derived([rawLaureates, width], ([$rawLaureates, $width]) => { 43 | const padding = 16; 44 | const scale = scaleLinear() 45 | .domain(extent($rawLaureates.filter(d => !isNaN(d.age_at_prize) && d.age_at_prize !== 'NA'), d => d.age_at_prize)) 46 | .range([padding, $width - padding]); 47 | return scale; 48 | }); 49 | 50 | export const sortedLaureates = derived([rawLaureates, currentStep], ([$rawLaureates, $currentStep]) => { 51 | if (!$currentStep) return []; 52 | const { cluster_by: clusterBy, exclusiveIds = [] } = $currentStep || {}; 53 | if (!clusterBy) return []; 54 | 55 | const categories = [...new Set($rawLaureates.map(d => d[clusterBy]).flat())]; 56 | 57 | const clusterData = categories.map(cat => { 58 | const data = $rawLaureates 59 | .filter(d => d[clusterBy][0] === cat) 60 | .filter(d => exclusiveIds.length ? exclusiveIds.includes(d.id) : true); 61 | return { 62 | category: cat, 63 | data 64 | }; 65 | }); 66 | 67 | return clusterData; 68 | }); 69 | 70 | export const laureates = derived( 71 | [sortedLaureates, currentStep, width, height, radius, ageScale], 72 | async ( 73 | [$sortedLaureates, $currentStep, $width, $height, $radius, $ageScale], 74 | set 75 | ) => { 76 | const { inner_layout, outer_layout, exclusiveIds = [] } = $currentStep || {}; 77 | const radiusFactor = exclusiveIds.length ? 6 : 1; 78 | 79 | const { type: typeInner } = inner_layout || {}; 80 | const { type: typeOuter } = outer_layout || {}; 81 | 82 | let layoutedInner = $sortedLaureates; 83 | if (typeInner === 'phyllotaxis') { 84 | layoutedInner = $sortedLaureates.map(({ category, data }) => { 85 | return layoutPhyllotaxis(data, $radius * radiusFactor, category); 86 | }); 87 | } else if (typeInner === 'histogram') { 88 | layoutedInner = (await Promise.all($sortedLaureates.map(async ({ category, data }, i, arr) => { 89 | const beeData = await layoutHistogram( 90 | data, 91 | $radius * radiusFactor / 2, 92 | d => $ageScale(d.age_at_prize), 93 | (i + 1) / arr.length * $height * 0.8, 94 | 'age_at_prize', 95 | (arr.length === 1 ? $radius * 2 : $radius) * radiusFactor, 96 | category 97 | ); 98 | return beeData; 99 | }))).map(d => ({index: 0, data: d})); 100 | } 101 | 102 | let layoutedOuter = layoutedInner; 103 | if (typeOuter === 'clusterForce') { 104 | layoutedOuter = await layoutClusterForce(layoutedInner, $width, $height, 2 * $radius * radiusFactor); 105 | } 106 | 107 | set(layoutedOuter); 108 | }, 109 | []); -------------------------------------------------------------------------------- /src/stores/devices.js: -------------------------------------------------------------------------------- 1 | import { derived, writable } from 'svelte/store'; 2 | 3 | import { width, height } from './dimensions'; 4 | 5 | export const isMobile = derived(width, $width => { 6 | return $width < 520; 7 | }); 8 | 9 | export const isSmall = derived(width, $width => { 10 | return $width < 720; 11 | }); 12 | 13 | export const headerHeight = writable(100); 14 | 15 | export const top = derived([height, headerHeight], ([$height, $headerHeight]) => { 16 | return $headerHeight / $height; 17 | }); 18 | 19 | export const bottom = derived([height, headerHeight], ([$height, $headerHeight]) => { 20 | return ($headerHeight + $height + 20) / $height; 21 | }); 22 | 23 | export const contentHeight = derived([height, headerHeight], ([$height, $headerHeight]) => { 24 | const availableHeight = $height - $headerHeight - 15; 25 | return Math.max(0, availableHeight); 26 | }); -------------------------------------------------------------------------------- /src/stores/dimensions.js: -------------------------------------------------------------------------------- 1 | import { writable, derived } from 'svelte/store'; 2 | 3 | export const width = writable(0); 4 | export const height = writable(0); 5 | export const canvasHeight = writable(0); 6 | 7 | export const minDim = derived([width, height], ([$width, $height]) => { 8 | return Math.min($width, $height); 9 | }); 10 | 11 | export const radius = derived(minDim, $minDim => { 12 | return Math.max(4, ($minDim ** 2) / 120000); 13 | }); -------------------------------------------------------------------------------- /src/stores/steps.js: -------------------------------------------------------------------------------- 1 | import { readable, writable } from 'svelte/store'; 2 | import { json } from 'd3'; 3 | 4 | const dataPath = 'steps.json'; 5 | 6 | export const steps = readable([], async set => { 7 | const data = await json(dataPath); 8 | 9 | const n = data.length - 1; 10 | const fData = data.map((d, i) => { 11 | return { 12 | id: i, 13 | ...d, 14 | position: i / n - (0.5 / n) 15 | }; 16 | }); 17 | 18 | set(fData); 19 | }); 20 | 21 | export const currentStep = writable(null); -------------------------------------------------------------------------------- /src/utils/canvas.js: -------------------------------------------------------------------------------- 1 | // adapted from https://github.com/Wattenberger/svelte-recipes/blob/master/src/components/examples/scale-canvas.js 2 | export const scaleCanvas = (canvas, ctx, width, height, center = true) => { 3 | // assume the device pixel ratio is 1 if the browser doesn't specify it 4 | const devicePixelRatio = window.devicePixelRatio || 1; 5 | 6 | // determine the 'backing store ratio' of the canvas ctx 7 | const backingStoreRatio = 8 | ctx.webkitBackingStorePixelRatio || 9 | ctx.mozBackingStorePixelRatio || 10 | ctx.msBackingStorePixelRatio || 11 | ctx.oBackingStorePixelRatio || 12 | ctx.backingStorePixelRatio || 13 | 1; 14 | 15 | // determine the actual ratio we want to draw at 16 | const ratio = devicePixelRatio / backingStoreRatio; 17 | 18 | if (devicePixelRatio !== backingStoreRatio) { 19 | // set the 'real' canvas size to the higher width/height 20 | canvas.width = width * ratio; 21 | canvas.height = height * ratio; 22 | 23 | // ...then scale it back down with CSS 24 | canvas.style.width = width + 'px'; 25 | canvas.style.height = height + 'px'; 26 | } else { 27 | // this is a normal 1:1 device; just scale it simply 28 | canvas.width = width; 29 | canvas.height = height; 30 | canvas.style.width = ''; 31 | canvas.style.height = ''; 32 | } 33 | 34 | // scale the drawing ctx so everything will work at the higher ratio 35 | ctx.scale(ratio, ratio); 36 | 37 | if (center) { 38 | ctx.translate(width / 2, height / 2); 39 | } 40 | }; -------------------------------------------------------------------------------- /src/utils/colors.js: -------------------------------------------------------------------------------- 1 | export const prizeCategoryColors = { 2 | 'Physiology or Medicine': '#69A2D4', 3 | 'Physics': '#A1C8E6', 4 | 'Chemistry': '#30709E', 5 | 'Literature': '#DBBF58', 6 | 'Peace': '#997B19', 7 | 'Economic Sciences': '#8FCC9C' 8 | }; -------------------------------------------------------------------------------- /src/utils/dictionary.js: -------------------------------------------------------------------------------- 1 | export const dictionary = { 2 | Chemistry: 'Chemie', 3 | 'Economic Sciences': 'Wirtschaft', 4 | Literature: 'Literatur', 5 | Peace: 'Frieden', 6 | Physics: 'Physik', 7 | 'Physiology or Medicine': 'Physiologie oder Medizin', 8 | 9 | female: 'Frauen', 10 | male: 'Männer', 11 | 12 | USA: 'USA', 13 | 'United Kingdom': 'Großbritannien', 14 | Germany: 'Deutschland', 15 | France: 'Frankreich', 16 | Sweden: 'Schweden', 17 | Japan: 'Japan', 18 | Poland: 'Polen', 19 | Russia: 'Russland', 20 | Canada: 'Kanada', 21 | Italy: 'Italien', 22 | Switzerland: 'Schweiz', 23 | Austria: 'Österreich', 24 | 'the Netherlands': 'Niederlande', 25 | China: 'China', 26 | Norway: 'Norwegen', 27 | Denmark: 'Dänemark', 28 | Scotland: 'Schottland', 29 | Philippines: 'Philippinen', 30 | Israel: 'Israel', 31 | Lithuania: 'Litauen', 32 | Turkey: 'Türkei', 33 | Egypt: 'Ägypten', 34 | 'South Africa' : 'Südafrika', 35 | NA : '', 36 | Pakistan : 'Pakistan', 37 | Vietnam : 'Vietnam', 38 | Ghana : 'Ghana', 39 | Croatia : 'Kroatien', 40 | Mexico : 'Mexiko', 41 | India : 'Indien', 42 | Spain : 'Spanien', 43 | Portugal : 'Portugal', 44 | Bangladesh : 'Bangladesch', 45 | Guatemala : 'Guatemala', 46 | Argentina : 'Argentinien', 47 | 'New Zealand': 'Neuseeland', 48 | Algeria: 'Algerien', 49 | Belgium: 'Belgien', 50 | Zimbabwe: 'Simbabwe', 51 | Hungary: 'Ungarn', 52 | Lebanon: 'Libanon', 53 | Finland: 'Finnland', 54 | Cyprus: 'Zypern', 55 | Iran: 'Iran', 56 | Bulgaria: 'Bulgarien', 57 | Romania: 'Rumänien', 58 | Venezuela: 'Venezuela', 59 | Liberia: 'Liberia', 60 | Ireland: 'Irland', 61 | Slovenia: 'Slowenien', 62 | Colombia: 'Kolumbien', 63 | Greece: 'Griechenland', 64 | Chile: 'Chile', 65 | Myanmar: 'Myanmar', 66 | Ukraine: 'Ukraine', 67 | Peru: 'Peru', 68 | Azerbaijan: 'Aserbaidschan', 69 | Belarus: 'Belarus', 70 | Morocco: 'Marokko', 71 | Brazil: 'Brasilien', 72 | Yemen: 'Jemen', 73 | Slovakia: 'Slowakei', 74 | Iraq: 'Irak', 75 | Iceland: 'Island', 76 | Luxembourg: 'Luxemburg', 77 | Kenya: 'Kenia', 78 | Indonesia: 'Indonesien', 79 | Nigeria: 'Nigeria', 80 | Taiwan: 'Taiwan', 81 | Latvia: 'Lettland', 82 | Ethiopia: 'Äthiopien', 83 | Madagascar: 'Madagaskar', 84 | 'Czech Republic': 'Tschechien', 85 | 'Trinidad and Tobago': 'Trinidad und Tobago', 86 | 'Faroe Islands (Denmark)': 'Färöer', 87 | 'Guadeloupe Island': 'Guadeloupe', 88 | 'Northern Ireland': 'Nordirland', 89 | 'East Timor': 'Osttimor', 90 | 'Costa Rica': 'Costa Rica', 91 | 'South Korea': 'Südkorea', 92 | 'Saint Lucia': 'Sankt Lucia', 93 | 'North Macedonia': 'Nordmazedonien', 94 | 'Bosnia and Herzegovina': 'Bosnien und Herzegowina', 95 | 'Democratic Republic of the Congo': 'Demokratische Republik Kongo', 96 | Australia: 'Australien' 97 | }; -------------------------------------------------------------------------------- /src/utils/format.js: -------------------------------------------------------------------------------- 1 | import { timeParse, formatDefaultLocale, timeFormatDefaultLocale, 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 | // the basic formatting function sued 10 | const p = { 11 | ...formatDefaultLocale, 12 | decimal: '.', 13 | currency: ['', '  €'], 14 | percent: ' %', 15 | nan: NA_STRING, 16 | thousands: ',', 17 | grouping: [3], 18 | }; 19 | 20 | export const formatTime = timeFormatDefaultLocale({ 21 | ...p, 22 | dateTime: '%a %b %e %X %Y', 23 | date: '%d.%m.%Y', 24 | time: '%H:%M:%S', 25 | periods: ['AM', 'PM'], 26 | days: [ 27 | 'Sonntag', 28 | 'Montag', 29 | 'Dienstag', 30 | 'Mittwoch', 31 | 'Donnerstag', 32 | 'Freitag', 33 | 'Samstag', 34 | ], 35 | shortDays: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'], 36 | months: [ 37 | 'Januar', 38 | 'Februar', 39 | 'März', 40 | 'April', 41 | 'Mai', 42 | 'Juni', 43 | 'Juli', 44 | 'August', 45 | 'September', 46 | 'Oktober', 47 | 'November', 48 | 'Dezember', 49 | ], 50 | shortMonths: [ 51 | 'Jan', 52 | 'Feb', 53 | 'Mär', 54 | 'Apr', 55 | 'Mai', 56 | 'Jun', 57 | 'Jul', 58 | 'Aug', 59 | 'Sep', 60 | 'Okt', 61 | 'Nov', 62 | 'Dez', 63 | ] 64 | }).format; 65 | 66 | const indicatorFormats = { 67 | 'year': timeFormat('%Y'), 68 | 'month-short': timeFormat('%b'), 69 | 'month-year': timeFormat('%b %Y'), 70 | 'day-month-year': timeFormat('%d. %B %Y') 71 | }; 72 | 73 | export const formatValue = (value, indicatorId) => { 74 | const formatter = indicatorFormats[indicatorId]; 75 | return formatter(value); 76 | }; 77 | 78 | export const parseDate = timeParse('%d.%m.%y'); 79 | 80 | export const formatPartyKeys = (obj) => { 81 | const keys = Object.keys(obj); 82 | let key; 83 | let n = keys.length; 84 | let newobj = {}; 85 | while (n--) { 86 | key = keys[n]; 87 | if (key === 'CDU' || key === 'CSU') key = 'CDU/CSU'; 88 | newobj[key.toUpperCase()] = obj[key]; 89 | } 90 | return newobj; 91 | }; -------------------------------------------------------------------------------- /src/utils/layout.js: -------------------------------------------------------------------------------- 1 | import { max, forceSimulation, forceCenter, forceCollide } from 'd3'; 2 | 3 | const goldenRatio = (1 + Math.sqrt(5)) / 2; 4 | 5 | export const layoutPhyllotaxis = ( 6 | data, 7 | radius = 4, 8 | category, 9 | radiusOffset = radius / 2, 10 | spacing = radius * 1.2, 11 | theta = 2 * Math.PI / goldenRatio 12 | ) => { 13 | const layoutedData = data.map((d, i) => { 14 | const scaledTheta = theta * i; 15 | const scaledRadius = spacing * Math.sqrt(i) + radiusOffset; 16 | 17 | return { 18 | ...d, 19 | radius, 20 | x: Math.cos(scaledTheta) * scaledRadius, 21 | y: Math.sin(scaledTheta) * scaledRadius, 22 | withinClusterIndex: i 23 | }; 24 | }); 25 | 26 | const clusterRadius = max(layoutedData.map(d => [Math.abs(d.x), Math.abs(d.y)]).flat()); 27 | 28 | const metaResult = { 29 | clusterRadius, 30 | category, 31 | data: layoutedData 32 | }; 33 | 34 | return metaResult; 35 | }; 36 | 37 | export const layoutClusterForce = (data, width, height, spacing = 0) => { 38 | return new Promise((resolve) => { 39 | let dataCopy = [...data]; 40 | 41 | forceSimulation() 42 | .nodes(dataCopy) 43 | .force('collision', forceCollide(d => (d.clusterRadius || 1) + spacing)) 44 | .force('center', forceCenter(width / 2, height / 2)) 45 | .on('tick', () => { 46 | for (let i = 0; i < dataCopy.length; i++) { 47 | const d = dataCopy[i]; 48 | const r = (d.clusterRadius || 1) + spacing; 49 | d.x = Math.max(r, Math.min(width - r, d.x)); 50 | d.y = Math.max(r, Math.min(height - r, d.y)); 51 | } 52 | }) 53 | .on('end', () => resolve(dataCopy)) 54 | .alphaMin(0.8); 55 | }); 56 | }; 57 | 58 | export const layoutHistogram = (data, radius = 1, x = d => d, fy = 0, catType, offset = radius * 2, category) => { 59 | const scaledData = data.map(d => ({...d, x: +x(d), y: 0})); 60 | 61 | const histo = scaledData.reduce((acc, cur) => { 62 | const entry = cur[catType].toString(); 63 | if (acc[entry]) { 64 | return {...acc, [entry]: [...acc[entry], {...cur, y: cur.y + offset * acc[entry].length}]}; 65 | } else { 66 | return {...acc, [entry]: [cur]}; 67 | } 68 | }, {}); 69 | 70 | const result = Object.values(histo).flat().map((d, i) => { 71 | return { 72 | ...d, 73 | category, 74 | y: -d.y + fy, 75 | radius, 76 | withinClusterIndex: i * 10 77 | }; 78 | }); 79 | 80 | return result; 81 | }; -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | // svelte.config.js 2 | const preprocess = require('svelte-preprocess'); 3 | 4 | module.exports = { 5 | preprocess: preprocess() 6 | }; 7 | --------------------------------------------------------------------------------