├── .gitignore
├── CNAME
├── README.md
├── babel.config.js
├── package-lock.json
├── package.json
├── public
├── CNAME
├── build
│ ├── bundle.css
│ ├── bundle.css.map
│ ├── bundle.js
│ └── bundle.js.map
├── global.css
├── images
│ ├── logos
│ │ ├── ac.svg
│ │ ├── dfrlab.svg
│ │ ├── fiat_balloons.png
│ │ └── fiat_blurry.png
│ ├── meta
│ │ ├── android-chrome-192x192.png
│ │ ├── android-chrome-512x512.png
│ │ ├── apple-touch-icon.png
│ │ ├── browserconfig.xml
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── favicon.ico
│ │ ├── mstile-144x144.png
│ │ ├── mstile-150x150.png
│ │ ├── mstile-310x150.png
│ │ ├── mstile-310x310.png
│ │ ├── mstile-70x70.png
│ │ ├── safari-pinned-tab.svg
│ │ └── site.webmanifest
│ └── screenshots
│ │ ├── fiat_image.jpg
│ │ └── fiat_placeholder.jpg
└── index.html
├── push_to_ghpages.sh
├── rollup.config.js
└── src
├── App.svelte
├── Catch.svelte
├── CookieBanner.svelte
├── actions
├── brushable.js
├── copytooltipable.js
├── draggable.js
├── slidable.js
└── spottooltipable.js
├── analytics.js
├── components
├── BackgroundChart.svelte
├── Balloon.svelte
├── Brush.svelte
├── Canvas.svelte
├── CaseDensity.svelte
├── Centroid.svelte
├── CentroidTooltip.svelte
├── Checkbox.svelte
├── CheckboxPanel.svelte
├── CibTable.svelte
├── Controls.svelte
├── CopyTooltip.svelte
├── CoronaChart.svelte
├── CreatedBy.svelte
├── Defs.svelte
├── Dropdown.svelte
├── Event.svelte
├── EventTooltip.svelte
├── EventTooltipCross.svelte
├── Events.svelte
├── GoogleTrendsChart.svelte
├── ImpactStrip.svelte
├── Info.svelte
├── Labels.svelte
├── Legend.svelte
├── LoadingInfo.svelte
├── PolarizationLegend.svelte
├── PolarizationStrip.svelte
├── ScoreBar.svelte
├── ScoreQuestions.svelte
├── SearchText.svelte
├── Share.svelte
├── ShinyCircle.svelte
├── Slider.svelte
├── SourceLink.svelte
├── Sources.svelte
├── SpotTooltip.svelte
├── Svg.svelte
├── Table.svelte
├── Timeline.svelte
├── TimelineSpot.svelte
├── TimelineSpots.svelte
├── ToTop.svelte
├── TopVisualContent.svelte
└── Visualization.svelte
├── inputs
├── cib.js
├── dataPaths.js
├── polarization.js
├── scores.js
└── table.js
├── main.js
├── stores
├── centroidSelections.js
├── dimensions.js
├── elements.js
├── eventSelections.js
├── filters.js
├── map.js
└── scales.js
├── transitions
├── constants.js
└── tween.js
└── utils
├── colors.js
├── loadCoronaData.js
├── loadData.js
├── loadGoogleTrendsData.js
├── loadMapData.js
├── loadSpots.js
├── misc.js
├── paths.js
├── scales.js
└── share.js
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules/
3 | public/build/
4 |
--------------------------------------------------------------------------------
/CNAME:
--------------------------------------------------------------------------------
1 | interference2020.org
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Interference2020
2 | > Interference2020 Website
3 |
4 | This repository contains the visualization tool running at [interference2020.org](https://interference2020.org)
5 |
6 | For the Interference2020 database, please go to [this repository](https://github.com/DFRLab/interference2020-Data).
7 |
8 | ## Research team
9 | [Emerson T. Brooking](https://twitter.com/etbrooking) (lead), [Alyssa Kann](https://twitter.com/AlyssaKann), [Max Rizzuto](https://twitter.com/maxbrizzuto), [Jacqueline Malaret](https://twitter.com/jacqumalaret), and Helen Simpson.
10 |
11 |
12 | ## Design and Implementation
13 | [Matthias Stahl](https://higsch.com) (higsch | data & design)
14 |
15 |
16 | 
17 |
18 |
19 | ## Architecture
20 | The data visualization has been built with HTML, CSS and JavaScript – heavily using the [Svelte](https://svelte.dev) frontend compiler. Interference data is dynamically pulled from a separate [data repository](https://github.com/DFRLab/interference2020-Data). The world map is pulled from [JSDELIVR](https://cdn.jsdelivr.net/npm/world-atlas@2/countries-50m.json) and uses the [World Atlas](https://www.npmjs.com/package/world-atlas) TopoJSON package. COVID-19 case and death numbers in the U.S. are collected from a repository managed by [The New York Times](https://github.com/nytimes/covid-19-data).
21 |
22 |
23 | ## Run locally
24 |
25 | 1. Download the repository and install the dependencies.
26 |
27 | ```bash
28 | git clone https://github.com/DFRLab/interference2020.git
29 | cd interference2020
30 | npm install
31 | ```
32 |
33 | 1. Start [Rollup](https://rollupjs.org)
34 |
35 | ```bash
36 | npm run dev
37 | ```
38 |
39 | 2. Navigate to [localhost:5000](http://localhost:5000). You should see your app running.
40 |
41 | *Built in August & September 2020.*
42 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | [
4 | '@babel/preset-env',
5 | {
6 | useBuiltIns: 'usage',
7 | corejs: 3,
8 | targets: '> 0.25%, not dead'
9 | },
10 | ],
11 | ],
12 | };
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fiat-visualization",
3 | "version": "0.9.0",
4 | "scripts": {
5 | "build": "rollup -c",
6 | "dev": "rollup -c -w",
7 | "start": "sirv public"
8 | },
9 | "devDependencies": {
10 | "@babel/core": "^7.11.6",
11 | "@babel/plugin-transform-spread": "^7.11.0",
12 | "@babel/preset-env": "^7.11.5",
13 | "@rollup/plugin-commonjs": "^14.0.0",
14 | "@rollup/plugin-node-resolve": "^8.0.0",
15 | "common-tags": "^1.8.0",
16 | "cookies-eu-banner": "^2.0.1",
17 | "core-js": "^3.6.5",
18 | "d3": "^6.1.1",
19 | "d3fc-discontinuous-scale": "^2.0.5",
20 | "flubber": "^0.4.2",
21 | "lodash": "^4.17.20",
22 | "node-sass": "^4.14.1",
23 | "rollup": "^2.3.4",
24 | "rollup-plugin-babel": "^4.4.0",
25 | "rollup-plugin-livereload": "^1.0.0",
26 | "rollup-plugin-postcss": "^3.1.8",
27 | "rollup-plugin-svelte": "^5.0.3",
28 | "rollup-plugin-terser": "^7.0.0",
29 | "svelte": "^3.0.0",
30 | "svelte-awesome": "^2.3.0",
31 | "topojson": "^3.0.2"
32 | },
33 | "dependencies": {
34 | "sirv-cli": "^1.0.0"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/public/CNAME:
--------------------------------------------------------------------------------
1 | interference2020.org
--------------------------------------------------------------------------------
/public/global.css:
--------------------------------------------------------------------------------
1 | /* set the global variables */
2 | :root {
3 | /* CI */
4 | --dfrlab-green: #00857d;
5 | --dfrlab-gray: #7e8083;
6 | --dfrlab-lightgray: #cacaca;
7 | --dfrlab-lightlightgray: #ECE9E9;
8 | --dfrlab-silver: #bfb8af;
9 | --dfrlab-white: #ffffff;
10 | --dfrlab-black: #000000;
11 |
12 | /* possible text colors */
13 | --text-black: #2a3035;
14 | --text-darkgray: #5e4a4a;
15 | --text-purple: #452f47;
16 |
17 | /* flames */
18 | --flame-03: #c26e8d;
19 | --flame-02: #d8808b;
20 | --flame-01: #e2a6ad;
21 | /* --flame-03: #a35b73;
22 | --flame-02: #f88773;
23 | --flame-01: #fac9b1; */
24 |
25 | --usa-red: #b22234;
26 | --usa-lightred: #c5888f;
27 | --usa-lightlightred: #dbb6b6;
28 | /* --usa-lightlightred: #DBBCB6; */
29 | --usa-blue: #3c3b6e;
30 | --usa-lightblue: #8f8eaa;
31 | --usa-lightlightblue: #c9c7eb;
32 | --usa-green: #a3b4a2;
33 | /* --usa-red: #b22234; */
34 |
35 | /* background */
36 | /* --bg: #e4dac7; */
37 | --bg: #F9F8F8;
38 | /* --bg: #eee9df; */
39 | --transparentbg: #F9F8F8cb;
40 |
41 | /* fonts */
42 | --font-01: Volkhov, serif;
43 | --font-02: Quicksand, sans-serif;
44 | }
45 |
46 | /* reset and page-wide settings */
47 | * {
48 | margin: 0;
49 | padding: 0;
50 | box-sizing: border-box;
51 | }
52 |
53 | html {
54 | -ms-overflow-style: -ms-autohiding-scrollbar;
55 | }
56 |
57 | html, body {
58 | width: 100%;
59 | height: 100%;
60 | font-size: 14px;
61 | }
62 |
63 | body {
64 | background-color: var(--bg);
65 | position: relative;
66 | -webkit-text-size-adjust: 100%;
67 | pointer-events: all;
68 | }
69 |
70 | #svelte-target {
71 | width: 100%;
72 | }
73 |
74 | /* switch font size for different device widths */
75 | @media (min-width: 600px) {
76 | html,
77 | body {
78 | font-size: 15px;
79 | }
80 | }
81 |
82 | @media (min-width: 980px) {
83 | html,
84 | body {
85 | font-size: 17px;
86 | }
87 | }
88 |
89 | @media (min-width: 1260px) {
90 | html,
91 | body {
92 | font-size: 18px;
93 | }
94 | }
95 |
96 | /* general classes */
97 | .disable-select {
98 | user-select: none; /* supported by Chrome and Opera */
99 | -webkit-user-select: none; /* Safari */
100 | -khtml-user-select: none; /* Konqueror HTML */
101 | -moz-user-select: none; /* Firefox */
102 | -ms-user-select: none; /* Internet Explorer/Edge */
103 | }
104 |
105 | a, span.pseudolink {
106 | color: var(--usa-blue);
107 | text-decoration: underline;
108 | cursor: pointer;
109 | transition: all 200ms ease;
110 | }
111 |
112 | a:hover, span.pseudolink:hover {
113 | color: var(--usa-lightblue);
114 | }
115 |
116 | .highlighted {
117 | margin: 0 -0.1rem;
118 | padding: 0 0.1rem;
119 | border: none;
120 | border-radius: 2px;
121 | background-color:#c95c68;
122 | }
123 |
124 | .no-pointer-events {
125 | pointer-events: none;
126 | }
127 |
128 | /* social media categories */
129 | .facebook {
130 | fill: #4267b2;
131 | background-color: #4267b2a1;
132 | }
133 |
134 | .twitter {
135 | fill: #1da1f2;
136 | background-color: #1da1f2a1;
137 | }
138 |
139 | .reddit {
140 | fill: #e24545;
141 | background-color: #e24545a1;
142 | }
143 |
144 | /* polarization */
145 | .pol-l {
146 | background-color: #2e64a0;
147 | opacity: 0.8;
148 | }
149 |
150 | .pol-ll {
151 | background-color: #61a3de;
152 | opacity: 0.8;
153 | }
154 |
155 | .pol-c {
156 | background-color: #96659e;
157 | opacity: 0.8;
158 | }
159 |
160 | .pol-lr {
161 | background-color: #a15552;
162 | opacity: 0.8;
163 | }
164 |
165 | .pol-r {
166 | background-color: #ca0800;
167 | opacity: 0.8;
168 | }
169 |
170 | .pol-undef {
171 | background-color: #e0d3e2;
172 | opacity: 0.8;
173 | }
174 |
175 | /* the landing page */
176 | .page-wrapper {
177 | width: 100%;
178 | padding: 2rem 0;
179 | position: relative;
180 | }
181 |
182 | section {
183 | font-family: var(--font-02);
184 | }
185 |
186 | .separator {
187 | width: 67%;
188 | max-width: 450px;
189 | margin: 1.5rem auto 1.5rem auto;
190 | border-bottom: 1px solid var(--dfrlab-gray);
191 | }
192 |
193 | .separator.thicker {
194 | border-width: 2px;
195 | border-color: var(--usa-blue);
196 | }
197 |
198 | .info-card {
199 | display: inline-block;
200 | margin: 0 -0.2rem 0 0.1rem;
201 | padding: 0 0.2rem;
202 | font-size: 0.9rem;
203 | font-weight: normal;
204 | color: var(--bg);
205 | border: none;
206 | border-radius: 2px;
207 | background-color: var(--usa-lightred);
208 | box-shadow: 0 1px 2px rgba(0,0,0,0.07),
209 | 0 2px 4px rgba(0,0,0,0.07);
210 | transform: translateY(-0.5rem);
211 | }
212 |
213 | .fiat-title-bg {
214 | width: 50%;
215 | max-width: 1100px;
216 | position: absolute;
217 | left: 50%;
218 | top: 30px;
219 | z-index: -1;
220 | }
221 |
222 | @media (min-width: 980px) {
223 | .fiat-title-bg {
224 | top: 0;
225 | }
226 | }
227 |
228 | @media (min-width: 1260px) {
229 | .fiat-title-bg {
230 | top: -50px;
231 | }
232 | }
233 |
234 | .fiat-title-bg img {
235 | width: 100%;
236 | position: relative;
237 | left: -50%;
238 | }
239 |
240 | section.content.title {
241 | padding-top: 1rem;
242 | position: relative;
243 | }
244 |
245 | section.title .logos {
246 | display: flex;
247 | align-items: center;
248 | justify-content: center;
249 | height: 2.2rem;
250 | }
251 |
252 | section.title .logos a {
253 | height: 100%;
254 | }
255 |
256 | section.title .logos a:not(:last-child) {
257 | margin-right: 1rem;
258 | }
259 |
260 | section.title .logos a img {
261 | height: 100%;
262 | }
263 |
264 | section.title .logos a.smaller {
265 | height: 80%;
266 | }
267 |
268 | section.content {
269 | width: 100%;
270 | max-width: 1100px;
271 | margin: 1rem auto;
272 | padding: 0 1.5rem;
273 | }
274 |
275 | .no-lower-margin, section.no-lower-margin {
276 | margin-bottom: 0;
277 | }
278 |
279 | section.no-upper-margin {
280 | margin-top: 0;
281 | }
282 |
283 | section h1 {
284 | margin: 0.5rem 0 0 0;
285 | font-family: var(--font-01);
286 | font-size: 3rem;
287 | color: var(--usa-blue);
288 | text-align: center;
289 | }
290 |
291 | section h2, section h3 {
292 | color: var(--text-black);
293 | }
294 |
295 | section h2 {
296 | margin: 0 0 1.5rem 0;
297 | font-size: 1.1rem;
298 | text-align: center;
299 | color: var(--usa-blue);
300 | }
301 |
302 | section h3 {
303 | margin: 1.2rem 0 0 0;
304 | font-size: 1rem;
305 | font-weight: normal;
306 | font-style: italic;
307 | }
308 |
309 | section h4 {
310 | margin: 1rem 0 0 0;
311 | font-size: 0.9rem;
312 | font-weight: normal;
313 | font-style: italic;
314 | }
315 |
316 | section h5 {
317 | margin: 0.8rem 0 0 0;
318 | font-size: 0.85rem;
319 | font-weight: normal;
320 | font-style: italic;
321 | }
322 |
323 | section span.copy-tooltip {
324 | margin: 0 -0.15rem;
325 | padding: 0 0.15rem;
326 | border: none;
327 | border-radius: 2px;
328 | background-color: var(--dfrlab-lightgray);
329 | }
330 |
331 | section p, section ul {
332 | margin: 0.7rem 0;
333 | font-size: 0.85rem;
334 | line-height: 1.6;
335 | }
336 |
337 | section p.center {
338 | text-align: center;
339 | }
340 |
341 | section p.smaller, section span.smaller {
342 | font-size: 0.75rem;
343 | }
344 |
345 | section p span.label {
346 | display: block;
347 | height: 100%;
348 | min-width: 7rem;
349 | }
350 |
351 | @media (min-width: 600px) {
352 | section p span.label {
353 | display: inline-block;
354 | }
355 | }
356 |
357 | section em {
358 | font-style: italic;
359 | text-decoration: none;
360 | color: var(--text-black);
361 | }
362 |
363 | /* collapsibles */
364 | input.toggle[type='checkbox'] {
365 | display: none;
366 | }
367 |
368 | .lbl-toggle {
369 | display: block;
370 | margin-left: -0.3rem;
371 | padding: 0.2rem 0;
372 | font-size: 0.9rem;
373 | font-weight: normal;
374 | font-style: italic;
375 | color: var(--text-black);
376 | cursor: pointer;
377 | transition: all 200ms ease;
378 | }
379 |
380 | .lbl-toggle.top {
381 | font-size: 0.85rem;
382 | font-weight: normal;
383 | font-style: normal;
384 | }
385 |
386 | .lbl-toggle::before {
387 | content: ' ';
388 | display: inline-block;
389 | border-top: 5px solid transparent;
390 | border-bottom: 5px solid transparent;
391 | border-left: 5px solid currentColor;
392 | vertical-align: middle;
393 | margin-top: 2px;
394 | transform: translateX(-0.6rem) translateY(-2px);
395 | transition: transform 200ms ease-out;
396 | }
397 |
398 | .collapsible-content {
399 | max-height: 0px;
400 | overflow: hidden;
401 | transition: max-height 200ms ease-in-out;
402 | }
403 |
404 | .collapsible-content h4, .collapsible-content h5 {
405 | margin: 1.2rem 1rem 0 1rem;
406 | }
407 |
408 | .collapsible-content p {
409 | padding: 0 1rem;
410 | }
411 |
412 | .toggle:checked + .lbl-toggle + .collapsible-content {
413 | max-height: 10000px;
414 | border: 1px solid var(--dfrlab-lightgray);
415 | border-radius: 3px;
416 | }
417 |
418 | .toggle:checked + .lbl-toggle::before {
419 | transform: rotate(90deg) translateY(0.6rem);
420 | }
421 |
422 | .toggle:checked + .lbl-toggle {
423 | border-bottom-right-radius: 0;
424 | border-bottom-left-radius: 0;
425 | }
426 |
427 | section ul {
428 | padding: 0 2.5rem;
429 | list-style-type: disc;
430 | }
431 |
432 | section ul li {
433 | margin: 0.2rem 0;
434 | line-height: 1.6;
435 | }
436 |
437 | section ul.inner {
438 | margin: 0;
439 | }
440 |
441 | section ul.filter-list {
442 | display: flex;
443 | flex-direction: column;
444 | align-items: flex-start;
445 | padding: 0;
446 | list-style-type: none;
447 | }
448 |
449 | section ul.filter-list li {
450 | margin: 0.2rem -0.1rem;
451 | padding: 0 0.1rem;
452 | color: var(--usa-blue);
453 | border-bottom: 2px solid var(--usa-blue);
454 | cursor: pointer;
455 | transition: all 200ms ease;
456 | }
457 |
458 | section ul.filter-list li:hover {
459 | color: var(--bg);
460 | border-radius: 3px;
461 | background-color: var(--usa-lightblue);
462 | }
463 |
464 | section ul.filter-list li:hover span.apply-filter {
465 | background-color: var(--usa-blue);
466 | }
467 |
468 | section ol {
469 | padding: 0 2.5rem;
470 | list-style-type: decimal;
471 | counter-reset: section;
472 | }
473 |
474 | section ol li {
475 | margin: 0.5rem 0;
476 | font-size: 0.85rem;
477 | line-height: 1.6;
478 | counter-increment: step-counter;
479 | content: counter(step-counter);
480 | }
481 |
482 | footer {
483 | display: flex;
484 | flex-direction: column;
485 | width: 100%;
486 | min-height: 3rem;
487 | margin-bottom: 1.5rem;
488 | }
489 |
490 | footer a, footer p {
491 | margin: 0.2rem 0;
492 | font-family: var(--font-02);
493 | font-size: 0.7rem;
494 | text-align: center;
495 | text-decoration: none;
496 | }
497 |
--------------------------------------------------------------------------------
/public/images/logos/ac.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/public/images/logos/dfrlab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 |
10 |
34 |
37 |
40 |
44 |
48 |
51 |
56 |
60 |
62 |
67 |
69 |
76 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/public/images/logos/fiat_balloons.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/logos/fiat_balloons.png
--------------------------------------------------------------------------------
/public/images/logos/fiat_blurry.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/logos/fiat_blurry.png
--------------------------------------------------------------------------------
/public/images/meta/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/meta/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/images/meta/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/meta/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/images/meta/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/meta/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/images/meta/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #da532c
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/images/meta/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/meta/favicon-16x16.png
--------------------------------------------------------------------------------
/public/images/meta/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/meta/favicon-32x32.png
--------------------------------------------------------------------------------
/public/images/meta/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/meta/favicon.ico
--------------------------------------------------------------------------------
/public/images/meta/mstile-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/meta/mstile-144x144.png
--------------------------------------------------------------------------------
/public/images/meta/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/meta/mstile-150x150.png
--------------------------------------------------------------------------------
/public/images/meta/mstile-310x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/meta/mstile-310x150.png
--------------------------------------------------------------------------------
/public/images/meta/mstile-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/meta/mstile-310x310.png
--------------------------------------------------------------------------------
/public/images/meta/mstile-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/meta/mstile-70x70.png
--------------------------------------------------------------------------------
/public/images/meta/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.11, written by Peter Selinger 2001-2013
9 |
10 |
12 |
76 |
83 |
88 |
95 |
103 |
107 |
108 |
109 |
--------------------------------------------------------------------------------
/public/images/meta/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/public/images/screenshots/fiat_image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/screenshots/fiat_image.jpg
--------------------------------------------------------------------------------
/public/images/screenshots/fiat_placeholder.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DFRLab/interference2020/886a240f76b5101f1861b25fd1e87aa0d04ea3a5/public/images/screenshots/fiat_placeholder.jpg
--------------------------------------------------------------------------------
/push_to_ghpages.sh:
--------------------------------------------------------------------------------
1 | git subtree push --prefix public origin gh-pages
2 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import babel from 'rollup-plugin-babel';
2 | import svelte from 'rollup-plugin-svelte';
3 | import resolve from '@rollup/plugin-node-resolve';
4 | import commonjs from '@rollup/plugin-commonjs';
5 | import postcss from 'rollup-plugin-postcss';
6 | import livereload from 'rollup-plugin-livereload';
7 | import { terser } from 'rollup-plugin-terser';
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 | // enable run-time checks when not in production
43 | dev: !production,
44 | // we'll extract any component CSS out into
45 | // a separate file - better for performance
46 | css: css => {
47 | css.write('public/build/bundle.css');
48 | },
49 |
50 | emitCss: true
51 | }),
52 | babel({
53 | extensions: ['.js', '.mjs', '.html', '.svelte'],
54 | include: [
55 | 'src/**',
56 | 'node_modules/svelte/**',
57 | 'node_modules/svelte-select/**',
58 | 'node_modules/d3/**',
59 | 'node_modules/d3-*/**'
60 | ],
61 | exclude: ['node_modules/@babel/**'],
62 | runtimeHelpers: true
63 | }),
64 |
65 | // If you have external dependencies installed from
66 | // npm, you'll most likely need these plugins. In
67 | // some cases you'll need additional configuration -
68 | // consult the documentation for details:
69 | // https://github.com/rollup/plugins/tree/master/packages/commonjs
70 | resolve({
71 | browser: true,
72 | dedupe: ['svelte']
73 | }),
74 | commonjs(),
75 | postcss({
76 | extract: true,
77 | minimize: true
78 | }),
79 |
80 | // In dev mode, call `npm run start` once
81 | // the bundle has been generated
82 | !production && serve(),
83 |
84 | // Watch the `public` directory and refresh the
85 | // browser on changes when not in production
86 | !production && livereload('public'),
87 |
88 | // If we're building for production (npm run build
89 | // instead of npm run dev), minify
90 | production && terser()
91 | ],
92 | watch: {
93 | clearScreen: false
94 | }
95 | };
96 |
--------------------------------------------------------------------------------
/src/App.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 | {#if (width < 600)}
15 |
16 | {:else if (/MSIE|Trident/.test(window.navigator.userAgent))}
17 |
18 | {:else}
19 |
20 | {/if}
21 |
22 |
23 |
30 |
--------------------------------------------------------------------------------
/src/Catch.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 | {content}
14 | Send Email with Link
15 |
16 |
17 |
18 |
19 |
20 |
51 |
--------------------------------------------------------------------------------
/src/CookieBanner.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
This page wants to use cookies.
8 |
Accept
9 |
Reject
10 |
Privacy
11 |
12 |
13 |
14 |
76 |
--------------------------------------------------------------------------------
/src/actions/brushable.js:
--------------------------------------------------------------------------------
1 | export function brushable(node) {
2 | let x;
3 | let startX;
4 | let width;
5 |
6 | function handleMousedown(event) {
7 | event.preventDefault();
8 |
9 | x = startX = event.clientX;
10 |
11 | node.dispatchEvent(
12 | new CustomEvent('brushstart', {
13 | detail: { x },
14 | })
15 | );
16 |
17 | node.classList.add('brushed');
18 |
19 | window.addEventListener('mousemove', handleMousemove);
20 | window.addEventListener('mouseup', handleMouseup);
21 | }
22 |
23 | function handleMousemove(event) {
24 | width = Math.abs(event.clientX - startX);
25 | if (event.clientX < startX) x = event.clientX;
26 |
27 | node.dispatchEvent(
28 | new CustomEvent('brush', {
29 | detail: { x, width },
30 | })
31 | );
32 | }
33 |
34 | function handleMouseup() {
35 | const x1 = x;
36 | const x2 = x + width;
37 |
38 | node.dispatchEvent(
39 | new CustomEvent('brushend', {
40 | detail: { x1, width, x2 },
41 | })
42 | );
43 |
44 | node.classList.remove('brushed');
45 |
46 | window.removeEventListener('mousemove', handleMousemove);
47 | window.removeEventListener('mouseup', handleMouseup);
48 | }
49 |
50 | node.addEventListener('mousedown', handleMousedown);
51 |
52 | return {
53 | destroy() {
54 | node.removeEventListener('mousedown', handleMousedown);
55 | },
56 | };
57 | }
58 |
--------------------------------------------------------------------------------
/src/actions/copytooltipable.js:
--------------------------------------------------------------------------------
1 | import CopyTooltip from '../components/CopyTooltip.svelte';
2 |
3 | export function copytooltipable(node, { content, showClickMessage = true }) {
4 | let component;
5 |
6 | function handleMouseleave(e) {
7 | component.$destroy();
8 | node.removeEventListener('mouseleave', handleMouseleave)
9 | }
10 |
11 | function handleMouseenter(e) {
12 | const target = document.body;
13 | const x = e.pageX;
14 | const y = e.pageY + 20;
15 |
16 | component = new CopyTooltip({
17 | target,
18 | props: {
19 | title: node.innerHTML,
20 | content,
21 | x,
22 | y,
23 | showClickMessage
24 | },
25 | intro: true
26 | });
27 |
28 | node.addEventListener('mouseleave', handleMouseleave)
29 | }
30 |
31 | node.addEventListener('mouseenter', handleMouseenter);
32 |
33 | return {
34 | destroy() {
35 | node.removeEventListener('mouseenter', handleMouseenter);
36 | },
37 | };
38 | };
39 |
--------------------------------------------------------------------------------
/src/actions/draggable.js:
--------------------------------------------------------------------------------
1 | export function draggable(node) {
2 | let isDown = false;
3 | let startX;
4 | let scrollLeft;
5 |
6 | function handleMousedown(event) {
7 | isDown = true;
8 | startX = event.pageX - node.offsetLeft;
9 | scrollLeft = node.scrollLeft;
10 |
11 | window.addEventListener('mouseleave', handleMouseup);
12 | window.addEventListener('mousemove', handleMousemove);
13 | window.addEventListener('mouseup', handleMouseup);
14 | }
15 |
16 | function handleMouseup() {
17 | isDown = false;
18 |
19 | window.removeEventListener('mouseleave', handleMouseup);
20 | window.removeEventListener('mousemove', handleMousemove);
21 | window.removeEventListener('mouseup', handleMouseup);
22 | }
23 |
24 | function handleMousemove(event) {
25 | if (!isDown) return;
26 | event.preventDefault();
27 | const x = event.pageX - node.offsetLeft;
28 | const walk = (x - startX) * 3;
29 | node.scrollLeft = scrollLeft - walk;
30 | }
31 |
32 | node.addEventListener('mousedown', handleMousedown);
33 |
34 | return {
35 | destroy() {
36 | node.removeEventListener('mousedown', handleMousedown);
37 | },
38 | };
39 | }
40 |
--------------------------------------------------------------------------------
/src/actions/slidable.js:
--------------------------------------------------------------------------------
1 | export function slidable(node) {
2 | let x;
3 | let left;
4 |
5 | function handleMousedown(event) {
6 | x = event.clientX;
7 |
8 | node.dispatchEvent(
9 | new CustomEvent('slidestart', {
10 | detail: { x },
11 | })
12 | );
13 |
14 | window.addEventListener('mousemove', handleMousemove);
15 | window.addEventListener('mouseup', handleMouseup);
16 | }
17 |
18 | function handleMousemove(event) {
19 | const dx = event.clientX - x;
20 | x = event.clientX;
21 |
22 | node.dispatchEvent(
23 | new CustomEvent('slide', {
24 | detail: { x, dx },
25 | })
26 | );
27 | }
28 |
29 | function handleMouseup(event) {
30 | x = event.clientX;
31 | left = node.offsetLeft;
32 |
33 | node.dispatchEvent(
34 | new CustomEvent('slideend', {
35 | detail: { x, left },
36 | })
37 | );
38 |
39 | window.removeEventListener('mousemove', handleMousemove);
40 | window.removeEventListener('mouseup', handleMouseup);
41 | }
42 |
43 | node.addEventListener('mousedown', handleMousedown);
44 |
45 | return {
46 | destroy() {
47 | node.removeEventListener('mousedown', handleMousedown);
48 | },
49 | };
50 | }
51 |
--------------------------------------------------------------------------------
/src/actions/spottooltipable.js:
--------------------------------------------------------------------------------
1 | import SpotTooltip from '../components/SpotTooltip.svelte';
2 | import { get } from 'svelte/store';
3 |
4 | export function spottooltipable(node, { data, target, top }) {
5 | let component;
6 |
7 | function handleMouseleave(e) {
8 | component.$destroy();
9 | node.removeEventListener('mouseleave', handleMouseleave)
10 | }
11 |
12 | function handleMouseenter(e) {
13 | if (!target) return;
14 |
15 | const { left } = node.getBoundingClientRect();
16 |
17 | component = new SpotTooltip({
18 | target,
19 | props: {
20 | data,
21 | x: left,
22 | y: top
23 | },
24 | intro: true
25 | });
26 |
27 | node.addEventListener('mouseleave', handleMouseleave)
28 | }
29 |
30 | node.addEventListener('mouseenter', handleMouseenter);
31 |
32 | return {
33 | destroy() {
34 | node.removeEventListener('mouseenter', handleMouseenter);
35 | },
36 | };
37 | };
38 |
--------------------------------------------------------------------------------
/src/analytics.js:
--------------------------------------------------------------------------------
1 | export const googleAnalytics = (gaID) => {
2 | window.dataLayer = window.dataLayer || []
3 | function gtag() { dataLayer.push(arguments) }
4 | gtag('js', new Date())
5 | gtag('config', gaID)
6 |
7 | const script = document.createElement('script')
8 | script.src = `https://www.googletagmanager.com/gtag/js?id=${gaID}`
9 | document.body.appendChild(script)
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/BackgroundChart.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 | {#each $contextData.filter((d) => d.selected) as dataset}
13 | {#if (dataset.id === 'corona')}
14 |
17 | {/if}
18 | {#if (/^gt_/.test(dataset.id))}
19 |
21 | {/if}
22 | {/each}
23 |
24 |
25 |
27 |
--------------------------------------------------------------------------------
/src/components/Balloon.svelte:
--------------------------------------------------------------------------------
1 |
26 |
27 |
34 | {#if (timePoint.recentlyAdded)}
35 |
39 | {/if}
40 |
45 |
50 |
51 |
52 |
105 |
--------------------------------------------------------------------------------
/src/components/Brush.svelte:
--------------------------------------------------------------------------------
1 |
84 |
85 |
86 |
87 |
88 |
96 |
105 |
106 | {#if ($originalTimeDomain)}
107 |
110 |
116 | Reset time scale
117 |
118 | {/if}
119 |
120 |
165 |
--------------------------------------------------------------------------------
/src/components/Canvas.svelte:
--------------------------------------------------------------------------------
1 |
39 |
40 |
41 |
42 |
48 |
--------------------------------------------------------------------------------
/src/components/CaseDensity.svelte:
--------------------------------------------------------------------------------
1 |
40 |
41 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
71 |
--------------------------------------------------------------------------------
/src/components/Centroid.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 | dispatch('click', {id: country, c: centroid, e})}
18 | on:mouseover|stopPropagation={(e) => dispatch('mouseover', {id: country, c: centroid, e})}>
19 |
24 | {#if (centroid.length > 5)}
25 |
26 | {centroid.filter((c) => c.show).length}
27 |
28 | {/if}
29 |
30 |
31 |
61 |
--------------------------------------------------------------------------------
/src/components/CentroidTooltip.svelte:
--------------------------------------------------------------------------------
1 |
78 |
79 | {#if ($tooltip)}
80 |
140 | {/if}
141 |
142 |
250 |
--------------------------------------------------------------------------------
/src/components/Checkbox.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
11 |
12 |
13 |
14 |
15 |
48 |
--------------------------------------------------------------------------------
/src/components/CheckboxPanel.svelte:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
27 | handleClick('polarization')}>
30 |
31 | Partisan Leaning Data Filter
32 |
33 |
34 |
35 |
36 | $polarizationFilter = e.detail} />
48 |
49 |
56 |
57 |
58 |
87 |
--------------------------------------------------------------------------------
/src/components/CibTable.svelte:
--------------------------------------------------------------------------------
1 |
19 |
20 |
21 |
22 |
23 |
24 | {#each cibColumnHeaders as { id, name } (id)}
25 | {name}
26 | {/each}
27 |
28 |
29 |
30 | {#each cibTableFields as field (field.id)}
31 |
32 | {#each cibColumnHeaders as { id, type } (id)}
33 |
34 | {#if (type === 'platformName')}
35 |
38 | {:else if (type === 'fieldName')}
39 | {field[type]}
40 | {:else if (data[field[type]] !== undefined)}
41 | {commaFormat(data[field[type]])}
42 | {/if}
43 |
44 | {/each}
45 |
46 | {/each}
47 |
48 |
49 |
50 | {#if (data.budgetTotalUsd && data.budgetTotalUsd > 0)}
51 |
Facebook advertising expenditures: $ {commaFormat(data.budgetTotalUsd)}.
52 | {/if}
53 |
54 |
55 |
56 |
103 |
--------------------------------------------------------------------------------
/src/components/Controls.svelte:
--------------------------------------------------------------------------------
1 |
47 |
48 | {#if (timePoints)}
49 |
50 |
51 | $textSearchFilter = e.detail}
54 | on:reset={() => textSearchFilter.reset()} />
55 | $attributionScoreFilter = e.detail} />
63 | disinformantNationFilter.select(e.detail)}
67 | on:itemsRemoved={(e) => disinformantNationFilter.unselect(e.detail)} />
68 | platformFilter.select(e.detail)}
71 | on:itemsRemoved={(e) => platformFilter.unselect(e.detail)} />
72 | sourceFilter.select(e.detail)}
77 | on:itemsRemoved={(e) => sourceFilter.unselect(e.detail)} />
78 | sourceCategoryFilter.select(e.detail)}
81 | on:itemsRemoved={(e) => sourceCategoryFilter.unselect(e.detail)} />
82 | methodFilter.select(e.detail)}
86 | on:itemsRemoved={(e) => methodFilter.unselect(e.detail)} />
87 | contextData.select(e.detail)}
91 | on:itemsRemoved={(e) => contextData.unselect(e.detail)} />
92 | handleButtonClick()}>
94 | Reset
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 | {/if}
104 |
105 |
174 |
--------------------------------------------------------------------------------
/src/components/CopyTooltip.svelte:
--------------------------------------------------------------------------------
1 |
29 |
30 |
42 |
43 |
80 |
--------------------------------------------------------------------------------
/src/components/CoronaChart.svelte:
--------------------------------------------------------------------------------
1 |
44 |
45 | {#if (data)}
46 |
48 |
49 |
51 |
53 |
54 | {#if (showLegend)}
55 |
57 |
58 |
59 | {commaFormat(lastItem.cases)} cases
60 |
61 |
62 |
64 |
65 |
66 | {commaFormat(lastItem.deaths)} deaths
67 |
68 |
69 |
71 | {#each ticks as tick}
72 |
74 |
75 | {commaFormat(tick)}
76 |
77 | {/each}
78 |
79 | {/if}
80 |
81 | {/if}
82 |
83 |
129 |
--------------------------------------------------------------------------------
/src/components/CreatedBy.svelte:
--------------------------------------------------------------------------------
1 |
3 |
4 |
8 |
9 |
33 |
--------------------------------------------------------------------------------
/src/components/Defs.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/components/Dropdown.svelte:
--------------------------------------------------------------------------------
1 |
48 |
49 | handleBodyClick(e)}>
50 |
51 |
52 |
53 | {label}
54 |
55 |
56 |
57 |
58 | {items.filter((d) => d.selected).length === 0
59 | ? 'none'
60 | : (items.every((d) => d.selected && items.length > 1)
61 | ? 'all'
62 | : items.filter((d) => d.selected).map((d) => d[nameField]).join(', '))}
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | {#if (expanded)}
72 |
73 |
74 | Select all
75 | Unselect all
76 |
77 |
78 | {#each items.sort((a, b) => -sortConsistently(a, b, 'id', 'id')) as item, i (item.id)}
79 | {#if (!(hideOneHitWonders && item.count === 1))}
80 |
81 | handleChoiceClick(item.id)}>
84 | {item[nameField]}
85 | {#if (item.liveCount)}
86 | ({item.liveCount})
87 | {:else if (item.source)}
88 | ({item.source})
89 | {/if}
90 |
91 |
92 | {/if}
93 | {/each}
94 |
95 | {#if (hideOneHitWonders)}
96 |
{label}s with only one result in the dataset are hidden.
97 | {/if}
98 |
99 | {/if}
100 |
101 |
102 |
103 |
240 |
--------------------------------------------------------------------------------
/src/components/Event.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 | {#if (timePoint.show)}
17 |
18 | export let hovered = false;
19 |
20 |
21 |
22 | {/if}
23 |
24 |
26 |
--------------------------------------------------------------------------------
/src/components/EventTooltipCross.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
20 |
21 |
22 |
23 |
24 |
38 |
--------------------------------------------------------------------------------
/src/components/Events.svelte:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 | {#each timePoints as timePoint (timePoint.id)}
24 | d.id).includes(timePoint.id)}
26 | hovered={$hovered && $hovered.id === timePoint.id}
27 | on:click={handleEventClick}
28 | on:mouseover={handleEventMouseover} />
29 | {/each}
30 |
31 |
32 |
39 |
--------------------------------------------------------------------------------
/src/components/GoogleTrendsChart.svelte:
--------------------------------------------------------------------------------
1 |
41 |
42 | {#if (data)}
43 |
45 |
46 |
47 |
48 |
60 |
61 | {/if}
62 |
63 |
81 |
--------------------------------------------------------------------------------
/src/components/ImpactStrip.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
18 | {commaFormat(value)}
19 |
20 |
21 | {label}
22 |
23 | {#if (polarization && (polarization.fulfills10Articles || polarization.fulfills25Percent) && value > 0)}
24 |
27 | {/if}
28 |
29 |
30 |
53 |
--------------------------------------------------------------------------------
/src/components/Info.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 | {#if (show && showCounter <= 2)}
16 |
19 |
i
20 |
Compare your selection in the document.querySelector('#table').scrollIntoView({behavior: 'smooth'})}>dataset view .
21 |
22 | {/if}
23 |
24 |
51 |
--------------------------------------------------------------------------------
/src/components/Labels.svelte:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
20 | {#each $timeScale.ticks(3) as tick}
21 |
23 |
24 | {tf(tick).replace('Jan 01, ', '')}
25 | {tf(tick).replace('Jan 01, ', '')}
26 |
27 | {/each}
28 |
35 |
36 |
37 | Attribution Date
40 | Attribution Date
43 |
45 |
46 |
47 |
49 | {#each $smiTotalYScale.ticks(5).slice(1) as tick}
50 |
52 | {commaFormat(tick)}
53 |
54 | {/each}
55 |
56 |
58 |
59 | Attribution Impact
63 |
65 |
66 |
68 |
69 | Disinformant Nations
73 |
74 |
75 |
76 |
77 |
116 |
--------------------------------------------------------------------------------
/src/components/Legend.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 |
16 |
18 | Attribution Impact
19 |
20 |
21 | {#each rTicks as tick, i}
22 |
26 |
28 | {commaFormat(tick)}
29 |
30 |
33 | {/each}
34 |
35 |
48 |
49 |
50 |
82 |
--------------------------------------------------------------------------------
/src/components/LoadingInfo.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
Loading and visualizing...
7 |
8 |
9 |
24 |
--------------------------------------------------------------------------------
/src/components/PolarizationLegend.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
Polarization:
7 |
8 | {#each categories as category (category)}
9 |
10 |
11 | {category.name}
12 |
13 | {/each}
14 |
15 |
16 |
17 |
53 |
--------------------------------------------------------------------------------
/src/components/PolarizationStrip.svelte:
--------------------------------------------------------------------------------
1 |
56 |
57 |
59 | {#if (width > 0)}
60 |
63 |
64 |
65 |
67 | {Math.round(engagementExplained * 100)}%
68 |
69 |
70 | {/if}
71 |
72 | {#each categories as category (category.id)}
73 |
75 |
76 | {/each}
77 |
78 |
79 |
80 |
111 |
--------------------------------------------------------------------------------
/src/components/ScoreBar.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
13 |
14 |
15 |
32 |
--------------------------------------------------------------------------------
/src/components/ScoreQuestions.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 | {#each scores as s}
13 |
{s.scoreName}: {timePoint[s.score]}/{maxScores[s.score]}
14 |
23 | {/each}
24 |
25 |
26 |
91 |
--------------------------------------------------------------------------------
/src/components/SearchText.svelte:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
30 |
{label}
31 |
|
32 |
reset()}>Reset
33 |
34 |
35 | handleKeyUp(e)} />
40 | {#if (searchString !== '')}
41 |
42 | searchString = ''}>
45 | x
46 |
47 | handleGoClick()}>
50 | Go
51 |
52 |
53 | {/if}
54 |
55 |
56 |
57 |
138 |
--------------------------------------------------------------------------------
/src/components/Share.svelte:
--------------------------------------------------------------------------------
1 |
47 |
48 |
49 |
{text}
50 |
57 |
60 |
61 |
62 |
63 |
64 |
93 |
--------------------------------------------------------------------------------
/src/components/ShinyCircle.svelte:
--------------------------------------------------------------------------------
1 |
35 |
36 |
41 | {#each sortedRadii as {id, className, r} (id)}
42 |
46 | {/each}
47 |
48 |
49 |
65 |
--------------------------------------------------------------------------------
/src/components/Slider.svelte:
--------------------------------------------------------------------------------
1 |
60 |
61 |
64 | {#if (showLabel)}
65 |
66 | {label}
67 |
68 | {/if}
69 |
70 |
75 |
handleSlide(e, 'left')}
80 | on:slideend={(e) => handleSlideEnd(e, 'left')}>
81 | {showHandleLabels ? Math.round(scale.invert(pos.left), 0) : ''}
82 |
83 |
handleSlide(e, 'right')}
88 | on:slideend={(e) => handleSlideEnd(e, 'right')}>
89 | {showHandleLabels ? Math.round(scale.invert(pos.right), 0) : ''}
90 |
91 |
92 |
93 |
94 |
157 |
--------------------------------------------------------------------------------
/src/components/SourceLink.svelte:
--------------------------------------------------------------------------------
1 |
37 |
38 | {#if (source.show)}
39 |
42 |
54 |
55 | {/if}
56 |
57 |
66 |
--------------------------------------------------------------------------------
/src/components/Sources.svelte:
--------------------------------------------------------------------------------
1 |
96 |
97 |
98 |
99 |
100 | {#each sources as source (source.idNation)}
101 | d.id).includes(source.id)
103 | ? 'selected'
104 | : ($eSelected && $eSelected.length > 0
105 | ? 'background'
106 | : 'unselected')}
107 | hovered={$eHovered && $eHovered.id === source.id
108 | ? 'selected'
109 | : ($eHovered
110 | ? 'background'
111 | : 'unselected')}
112 | extraFaint={source.outOfTimeRange}
113 | showPolarizationColor={$highlightPolarization} />
114 | {/each}
115 | {#each centroids as [country, centroid]}
116 | d.selected).map((d) => d.id).includes(country)}
119 | on:click={handleCentroidClick}
120 | on:mouseover={handleCentroidMouseover} />
121 | {/each}
122 |
123 |
124 |
126 |
--------------------------------------------------------------------------------
/src/components/SpotTooltip.svelte:
--------------------------------------------------------------------------------
1 |
30 |
31 |
47 |
48 |
84 |
--------------------------------------------------------------------------------
/src/components/Svg.svelte:
--------------------------------------------------------------------------------
1 |
25 |
26 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
52 |
--------------------------------------------------------------------------------
/src/components/Table.svelte:
--------------------------------------------------------------------------------
1 |
65 |
66 |
69 |
72 |
73 |
74 |
75 |
76 | {#each columns as column (column.property)}
77 |
78 | {column.name}
79 | {#if (column.sortable)}
80 | handleSorterClick(column.property)}>
85 |
86 |
87 | {/if}
88 |
89 | {/each}
90 |
91 |
92 |
93 | {#each rows.filter((d) => d.search.indexOf(searchString.toUpperCase()) > -1) as row, i (row.id)}
94 | d.id).includes(row.id)}>
96 | {#each columns as column (column.property)}
97 |
98 | {#if (row[column.property] === undefined || row[column.property] === null)}
99 | {''}
100 | {:else if (column.format)}
101 | {column.format(row[column.property])}
102 | {:else if (column.hyperlink)}
103 | Link
104 | {:else}
105 | {row[column.property]}
106 | {/if}
107 |
108 | {/each}
109 |
110 | {/each}
111 |
112 |
113 |
114 |
115 |
116 |
249 |
--------------------------------------------------------------------------------
/src/components/Timeline.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
25 |
--------------------------------------------------------------------------------
/src/components/TimelineSpot.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
19 |
20 |
21 |
22 |
23 |
46 |
--------------------------------------------------------------------------------
/src/components/TimelineSpots.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 | {#each spots as spot (spot.id)}
19 |
20 | {/each}
21 |
22 |
23 |
24 |
26 |
--------------------------------------------------------------------------------
/src/components/ToTop.svelte:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
23 | {#if (show)}
24 |
25 | handleSpanClick('top')}>To Top
26 | handleSpanClick('viz')}>To Visualization
27 |
28 | {/if}
29 |
30 |
53 |
--------------------------------------------------------------------------------
/src/components/TopVisualContent.svelte:
--------------------------------------------------------------------------------
1 |
43 |
44 | {#if (data.length > 0)}
45 |
46 |
47 | Overview
48 |
49 |
50 | This project builds public attribution standards, provides an independent and reliable record of foreign interference in the 2020 election, serves as a resource for stakeholders about the evolving threat, and helps to build public resilience against future foreign interference efforts and disinformation. It has been created in service of the DFRLab's mission to identity, expose, and explain disinformation and to promote objective fact as the basis for governance worldwide.
51 |
52 |
53 | The FIAT dataset contains {data.length} allegations of foreign interference originating from {$disinformantNationFilter.length} nations . Stories regarding these claims have received a cumulative {commaFormat(data.map((d) => d.smiTotal).filter((d) => !isNaN(d)).reduce((acc, cur) => acc + cur))} social media shares and engagements . The dataset was last updated on {tf(data.map((d) => d.timestamp).sort((a, b) => a > b ? 1 : -1).slice(-1)[0])} .
54 |
55 |
56 | This tool will be regularly updated as further allegations or attributions of foreign interference in the 2020 U.S. election are made public. If you have questions regarding the tool or would like to submit a case for consideration, please contact the DFRLab .
57 |
58 |
59 |
60 | How To Use This Tool
61 |
62 |
63 | FIAT consists of six elements that work together in order to tell the complete story of foreign interference allegations in the 2020 U.S. elections.
64 |
65 |
66 | Filters enable users to adjust the visibility of cases by scrollTo('attribution-score', 'collapsible-methodology')} use:copytooltipable={{content: 'The Attribution Score is a framework of eighteen binary statements (true or false) intended to assess the credibility, objectivity, evidence, and transparency of a given case.'}}>Attribution Score , scrollTo('source', 'collapsible-codebook')} use:copytooltipable={{content: 'Disinformant Nation is the nation from which the case allegedly originated. This does not necessarily denote that the activity was associated with a government.'}}>Disinformant Nation , scrollTo('platform', 'collapsible-codebook')} use:copytooltipable={{content: 'Platform(s) on which the case allegedly took place, divided between the open web, social media platforms, messaging platforms, and other platforms like email and forum boards.'}}>Platform , scrollTo('method', 'collapsible-codebook')} use:copytooltipable={{content: 'Method(s) involved in both the creation and amplification of content related to the case. Sockpuppets are one method; hacking by means of data exfiltration is another.'}}>Method , scrollTo('source', 'collapsible-codebook')} use:copytooltipable={{content: 'Source describes the individual or entity that originated a foreign interference claim.'}}>Source , and scrollTo('source', 'collapsible-codebook')} use:copytooltipable={{content: 'Source Category is the broad classification (e.g. Government, Technology Company) of the Source of a given case.'}}>Source Category . Free text search is also supported. This view also supports the addition of contextual datasets.
67 |
68 |
69 | Case View shows a series of interactable circles of various sizes and heights, each of which represents one case. The transparency of the circles corresponds to their Attribution Score. The radii of the circles correspond to the scrollTo('attribution-impact', 'collapsible-methodology')} use:copytooltipable={{content: 'Attribution Impact measures the spread of case-related articles and content over the seven days following a foreign interference allegation. It is a sum of Facebook engagements, Twitter shares, and Reddit engagements.'}}>Attribution Impact of the case, measured on a square root scale with a built-in minimum size for cases without Attribution Impact. The height of the circles also corresponds to the Attribution Impact, measured on a logarithmic scale. Therefore, difference in both the size and height of two given cases indicates exponential variation in their Attribution Impact. The individual thickness of the three surrounding rings represents the relative contribution of Facebook, Twitter and Reddit shares and engagements scaled by total Attribution Impact. This allows for a direct within case comparison. Cases are ordered chronologically by scrollTo('activity-attribution-date', 'collapsible-codebook')} use:copytooltipable={{content: 'Date of Attribution indicates the publication date of the attribution upon which the case is based.'}}>Date of Attribution , from left to right. Cases are attached to a tail that indicates one or more Disinformant Nations. Two or more cases can be selected to compare them in the Dataset View .
70 |
71 |
72 | Case Tooltips are accessible by hovering over a given case. This enables users to see the scrollTo('source', 'collapsible-codebook')} use:copytooltipable={{content: 'Attribution Type indicates how strongly a case is associated with a particular political actor. It denotes the difference between a state-directed military operation versus the efforts of a political troll farm.'}}>Attribution Type , Date of Attribution, the scrollTo('activity-attribution-date', 'collapsible-codebook')} use:copytooltipable={{content: 'Date(s) of Activity capture the earliest and latest activity alleged in a given case. Not always available.'}}>Date(s) of Activity , and a scrollTo('description-link', 'collapsible-codebook')} use:copytooltipable={{content: 'Narrative description of a given case, including Source, Disinformation, and Disinformant Nation. Describes the number and reach (when known) of digital assets employed.'}}>Description of a given case. Users can also see a breakdown of a case’s Attribution Score by its four subsections (Credibility, Objectivity, Evidence, and Transparency); clicking on the question mark on the right-hand corner of this view also expands the full scorecard. Platforms, Methods, Source, Source Category, and scrollTo('description-link', 'collapsible-codebook')} use:copytooltipable={{content: 'Link of Attribution links to the source claim upon which assessments are based. An archived link is available in the full downloadable dataset.'}}>Link of Attribution are also presented in the tooltip and can be clicked to auto-filter the Case View accordingly.
73 |
74 |
75 | Timeline View enables cases to be ordered chronologically from left to right. Noteworthy U.S. events in the U.S. 2020 election cycle are plotted on the timeline for context and reference. Additional timeline elements can be introduced with the Context Datasets filter. By clicking and dragging on the timeline, users can filter their view to a particular date range. They can return to the default view by clicking "Reset time scale" on the left-hand side of the timeline.
76 |
77 |
78 | Map View shows a Mercator projection of the Earth. Cases are plotted on the map by means of tails connected to particular Disinformant Nations: the more lines a particular country has originating from it, the more it has been implicated in allegations of foreign interference. By hovering over a particular country, users can see a density plot of attributions over time (if n > 5), as well as breakdowns of Platform, Method, Source, and Source Category. Each of these can be auto-filtered together with the selected country.
79 |
80 |
81 | Dataset View presents a simplified spreadsheet view of the FIAT dataset. Cases are affected by all applied filters. By clicking on one or more cases in the Case View, users can "pin" them to the top of the Dataset View for easy comparison. The Dataset View for easy comparison. The full dataset can also be downloaded from this view.
82 |
83 |
84 |
85 |
86 |
87 | FIAT enables users to filter and examine foreign interference allegations in a multitude of ways. As you get started, consider filtering by:
88 |
89 |
90 | handleApplyFilter(0)}>China-Related Allegations as Compared to U.S. COVID-19 Cases
91 | handleApplyFilter(1)}>All Foreign Interference Allegations Made by Facebook with CIB Data
92 | handleApplyFilter(2)}>All Foreign Interference Allegations That Lack Significant Evidence
93 |
94 |
97 |
98 | {/if}
99 |
100 |
102 |
--------------------------------------------------------------------------------
/src/inputs/cib.js:
--------------------------------------------------------------------------------
1 | export const cibTableFields = [
2 | {
3 | number: 'accountsTotalFb',
4 | followers: null,
5 | platformName: 'Facebook',
6 | fieldName: 'Accounts'
7 | },
8 | {
9 | number: 'pagesTotalFb',
10 | followers: 'pagesFollowersTotalFb',
11 | platformName: 'Facebook',
12 | fieldName: 'Pages'
13 | },
14 | {
15 | number: 'groupsTotalFb',
16 | followers: 'groupsFollowersTotalFb',
17 | platformName: 'Facebook',
18 | fieldName: 'Groups'
19 | },
20 | {
21 | number: 'eventsTotal',
22 | followers: null,
23 | platformName: 'Facebook',
24 | fieldName: 'Events'
25 | },
26 | {
27 | number: 'accountsTotalIg',
28 | followers: 'followersTotalIg',
29 | platformName: 'Instagram',
30 | fieldName: 'Accounts'
31 | }
32 | ].map((d, i) => ({id: i, ...d}));
33 |
34 | export const cibColumnHeaders = [
35 | {
36 | name: '',
37 | type: 'platformName'
38 | },
39 | {
40 | name: '',
41 | type: 'fieldName'
42 | },
43 | {
44 | name: 'Entities',
45 | type: 'number'
46 | },
47 | {
48 | name: 'Followers',
49 | type: 'followers'
50 | }
51 | ].map((d, i) => ({id: i, ...d}));
52 |
--------------------------------------------------------------------------------
/src/inputs/dataPaths.js:
--------------------------------------------------------------------------------
1 | export const data = 'https://raw.githubusercontent.com/DFRLab/interference2020-Data/master/DFRLab_interference2020.csv';
2 | // export const data = '/data/DFRLab_interference2020.csv';
3 | export const spotData = 'https://raw.githubusercontent.com/DFRLab/interference2020-Data/master/meta/timeline_dates.csv';
4 | export const coronaData = 'https://raw.githubusercontent.com/nytimes/covid-19-data/master/us.csv';
5 | export const images = 'https://raw.githubusercontent.com/DFRLab/interference2020-Data/master/images/';
6 | export const googleTrendsApiPath = 'https://stormy-cove-42135.herokuapp.com/keyword/';
7 |
--------------------------------------------------------------------------------
/src/inputs/polarization.js:
--------------------------------------------------------------------------------
1 | export const categories = [
2 | {
3 | id: 'l',
4 | name: 'left',
5 | weight: -2
6 | },
7 | {
8 | id: 'll',
9 | name: 'lean left',
10 | weight: -1
11 | },
12 | {
13 | id: 'c',
14 | name: 'center',
15 | weight: 0
16 | },
17 | {
18 | id: 'lr',
19 | name: 'lean right',
20 | weight: 1
21 | },
22 | {
23 | id: 'r',
24 | name: 'right',
25 | weight: 2
26 | },
27 | ];
28 |
--------------------------------------------------------------------------------
/src/inputs/scores.js:
--------------------------------------------------------------------------------
1 | import { uniqBy } from 'lodash';
2 |
3 | export const questions = [
4 | {
5 | id: 0,
6 | text: 'The source of the attribution does not have a direct financial interest in a certain attribution outcome.',
7 | column: 'attribution_financial_incentive',
8 | score: 'attributionCredibilityScore',
9 | scoreName: 'Credibility'
10 | },
11 | {
12 | id: 1,
13 | text: 'The source of the attribution has a diversified and transparent funding stream.',
14 | column: 'attribution_financial_transparency',
15 | score: 'attributionCredibilityScore',
16 | scoreName: 'Credibility'
17 | },
18 | {
19 | id: 2,
20 | text: 'The source of the attribution does not strongly endorse a specific political ideology.',
21 | column: 'attribution_endorse_political',
22 | score: 'attributionCredibilityScore',
23 | scoreName: 'Credibility'
24 | },
25 | {
26 | id: 3,
27 | text: 'The source of the attribution is in no way affiliated with a political campaign.',
28 | column: 'attribution_campaign_affiliation',
29 | score: 'attributionCredibilityScore',
30 | scoreName: 'Credibility'
31 | },
32 | {
33 | id: 4,
34 | text: 'The source of the attribution has not previously promoted mis- or disinformation.',
35 | column: 'attribution_published_mis_disinfo',
36 | score: 'attributionCredibilityScore',
37 | scoreName: 'Credibility'
38 | },
39 | {
40 | id: 5,
41 | text: 'The attribution avoids using biased wording. The attribution avoids high-inference or emotive language.',
42 | column: 'attribution_language',
43 | score: 'attributionObjectivityScore',
44 | scoreName: 'Objectivity'
45 | },
46 | {
47 | id: 6,
48 | text: 'The headline accurately conveys the content of the attribution.',
49 | column: 'attribution_convey_content',
50 | score: 'attributionObjectivityScore',
51 | scoreName: 'Objectivity'
52 | },
53 | {
54 | id: 7,
55 | text: 'The attribution clearly distinguishes factual information from argumentative analysis.',
56 | column: 'attribution_factual_argument',
57 | score: 'attributionObjectivityScore',
58 | scoreName: 'Objectivity'
59 | },
60 | {
61 | id: 8,
62 | text: 'The attribution provides a clear illustration of the methodology, tactics, and platforms involved in the alleged information operation.',
63 | column: 'attribution_clarity',
64 | score: 'attributionEvidenceScore',
65 | scoreName: 'Evidence'
66 | },
67 | {
68 | id: 9,
69 | text: 'The attribution contextualizes the engagement with, and impact of, the alleged information operation.',
70 | column: 'attribution_context',
71 | score: 'attributionEvidenceScore',
72 | scoreName: 'Evidence'
73 | },
74 | {
75 | id: 10,
76 | text: 'The attribution identifies actors and states allegedly responsible.',
77 | column: 'attribution_identified_responsibility',
78 | score: 'attributionEvidenceScore',
79 | scoreName: 'Evidence'
80 | },
81 | {
82 | id: 11,
83 | text: 'The attribution clearly explains the strategic goal and rationale of the actors who conducted the alleged information operation.',
84 | column: 'attribution_strategic_rationale',
85 | score: 'attributionEvidenceScore',
86 | scoreName: 'Evidence'
87 | },
88 | {
89 | id: 12,
90 | text: 'The attribution relies on information which is unique to, or can only be procured by, the relevant actor. (e.g. classified information for U.S. federal agencies, back-end/developer information for technology companies)',
91 | column: 'attribution_privileged_evidence',
92 | score: 'attributionEvidenceScore',
93 | scoreName: 'Evidence'
94 | },
95 | {
96 | id: 13,
97 | text: 'The attribution provides open access to a dataset or archived links of alleged assets.',
98 | column: 'attribution_open_access',
99 | score: 'attributionTransparencyScore',
100 | scoreName: 'Transparency'
101 | },
102 | {
103 | id: 14,
104 | text: 'The attribution methodology is clearly explained.',
105 | column: 'attribution_methodology',
106 | score: 'attributionTransparencyScore',
107 | scoreName: 'Transparency'
108 | },
109 | {
110 | id: 15,
111 | text: 'The attribution is replicable through open-source evidence.',
112 | column: 'attribution_open_source',
113 | score: 'attributionTransparencyScore',
114 | scoreName: 'Transparency'
115 | },
116 | {
117 | id: 16,
118 | text: 'The attribution acknowledges relevant limitations or mitigating factors in its assessment.',
119 | column: 'attribution_acknowledge_limitations',
120 | score: 'attributionTransparencyScore',
121 | scoreName: 'Transparency'
122 | },
123 | {
124 | id: 17,
125 | text: 'The attribution has been corroborated by a third party or independent investigation.',
126 | column: 'attribution_corroboration',
127 | score: 'attributionTransparencyScore',
128 | scoreName: 'Transparency'
129 | }
130 | ];
131 |
132 | export const scores = uniqBy(questions.map((d) => ({score: d.score, scoreName: d.scoreName})), 'score');
133 |
134 | export const maxScores = (() => {
135 | let res = {};
136 | scores.forEach((s) => {
137 | res = {...res, [s.score]: questions.filter((d) => d.score === s.score).length};
138 | });
139 | return(res);
140 | })();
141 |
--------------------------------------------------------------------------------
/src/inputs/table.js:
--------------------------------------------------------------------------------
1 | import { timeFormat, format } from 'd3';
2 |
3 | const commaFormat = format(',');
4 |
5 | export const columns = [
6 | {
7 | property: 'source',
8 | name: 'Source',
9 | sortable: true,
10 | classes: 'left',
11 | minWidth: '100px',
12 | format: (d) => d.join(', ')
13 | },
14 | {
15 | property: 'sourceNation',
16 | name: 'Source Nation',
17 | sortable: true,
18 | classes: 'left',
19 | minWidth: '100px'
20 | },
21 | {
22 | property: 'sourceCategory',
23 | name: 'Source Category',
24 | sortable: true,
25 | classes: 'left',
26 | minWidth: '100px',
27 | format: (d) => d.join(', ')
28 | },
29 | {
30 | property: 'disinformant',
31 | name: 'Disinformant',
32 | sortable: true,
33 | classes: 'left',
34 | minWidth: '100px'
35 | },
36 | {
37 | property: 'disinformantNation',
38 | name: 'Disinformant Nation',
39 | sortable: true,
40 | classes: 'left',
41 | minWidth: '100px',
42 | format: (d) => d.join(', ')
43 | },
44 | {
45 | property: 'disinformantAttribution',
46 | name: 'Disinformant Attribution',
47 | sortable: true,
48 | classes: 'left',
49 | minWidth: '100px'
50 | },
51 | {
52 | property: 'shortTitle',
53 | name: 'Title',
54 | sortable: true,
55 | classes: 'left',
56 | minWidth: '100px'
57 | },
58 | {
59 | property: 'shortDescription',
60 | name: 'Description',
61 | sortable: true,
62 | classes: 'left',
63 | minWidth: '500px'
64 | },
65 | {
66 | property: 'attributionUrl',
67 | name: 'Link',
68 | sortable: true,
69 | classes: 'left',
70 | hyperlink: true,
71 | minWidth: '100px'
72 | },
73 | {
74 | property: 'openWeb',
75 | name: 'Open Web',
76 | sortable: true,
77 | classes: 'left',
78 | minWidth: '100px'
79 | },
80 | {
81 | property: 'socialMedia',
82 | name: 'Social Media',
83 | sortable: true,
84 | classes: 'left',
85 | minWidth: '100px'
86 | },
87 | {
88 | property: 'messagingPlatforms',
89 | name: 'Messaging Platforms',
90 | sortable: true,
91 | classes: 'left',
92 | minWidth: '100px'
93 | },
94 | {
95 | property: 'other',
96 | name: 'Other',
97 | sortable: true,
98 | classes: 'left',
99 | minWidth: '100px'
100 | },
101 | {
102 | property: 'startDate',
103 | name: 'Start Date',
104 | sortable: true,
105 | format: timeFormat('%B %d, %Y'),
106 | classes: 'left',
107 | minWidth: '200px'
108 | },
109 | {
110 | property: 'endDate',
111 | name: 'End Date',
112 | sortable: true,
113 | format: timeFormat('%B %d, %Y'),
114 | classes: 'left',
115 | minWidth: '200px'
116 | },
117 | {
118 | property: 'attributionDate',
119 | name: 'Attribution Date',
120 | sortable: true,
121 | format: timeFormat('%B %d, %Y'),
122 | classes: 'left',
123 | minWidth: '200px'
124 | },
125 | {
126 | property: 'methods',
127 | name: 'Methods',
128 | sortable: true,
129 | classes: 'left',
130 | format: (d) => d.join(', '),
131 | minWidth: '100px'
132 | },
133 | {
134 | property: 'smiFacebook',
135 | name: 'Facebook Engagement',
136 | sortable: true,
137 | classes: 'right',
138 | minWidth: '100px',
139 | format: (d) => commaFormat(d)
140 | },
141 | {
142 | property: 'smiTwitter',
143 | name: 'Twitter Engagement',
144 | sortable: true,
145 | classes: 'right',
146 | minWidth: '100px',
147 | format: (d) => commaFormat(d)
148 | },
149 | {
150 | property: 'smiReddit',
151 | name: 'Reddit Engagement',
152 | sortable: true,
153 | classes: 'right',
154 | minWidth: '100px',
155 | format: (d) => commaFormat(d)
156 | },
157 | {
158 | property: 'smiTotal',
159 | name: 'Total Engagement',
160 | sortable: true,
161 | classes: 'right',
162 | minWidth: '100px',
163 | format: (d) => commaFormat(d)
164 | },
165 | {
166 | property: 'attributionCredibilityScore',
167 | name: 'Credibility Score',
168 | sortable: true,
169 | classes: 'right',
170 | minWidth: '100px'
171 | },
172 | {
173 | property: 'attributionObjectivityScore',
174 | name: 'Objectivity Score',
175 | sortable: true,
176 | classes: 'right',
177 | minWidth: '100px'
178 | },
179 | {
180 | property: 'attributionEvidenceScore',
181 | name: 'Evidence Score',
182 | sortable: true,
183 | classes: 'right',
184 | minWidth: '100px'
185 | },
186 | {
187 | property: 'attributionTransparencyScore',
188 | name: 'Transparency Score',
189 | sortable: true,
190 | classes: 'right',
191 | minWidth: '100px'
192 | },
193 | {
194 | property: 'attributionScore',
195 | name: 'Attribution Score',
196 | sortable: true,
197 | classes: 'right',
198 | minWidth: '100px'
199 | }
200 | ];
201 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | import App from './App.svelte';
2 |
3 | import { googleAnalytics } from './analytics.js';
4 | import CookiesEuBanner from '../node_modules/cookies-eu-banner/dist/cookies-eu-banner.min.js';
5 |
6 | const app = new App({
7 | target: document.querySelector('#svelte-target'),
8 | });
9 |
10 | new CookiesEuBanner(function () {
11 | googleAnalytics('UA-42996383-16');
12 | }, true);
13 |
14 | export default app;
15 |
--------------------------------------------------------------------------------
/src/stores/centroidSelections.js:
--------------------------------------------------------------------------------
1 | import { writable, derived } from 'svelte/store';
2 |
3 | export const hovered = writable(null);
4 | export const tooltip = derived(hovered, ($hovered) => {
5 | return $hovered;
6 | });
7 |
--------------------------------------------------------------------------------
/src/stores/dimensions.js:
--------------------------------------------------------------------------------
1 | import { writable, derived } from 'svelte/store';
2 |
3 | export const width = writable();
4 | export const height = writable();
5 |
6 | export const panelHeight = derived(height, ($height) => ($height / 2));
7 | export const mapHeight = derived([height, panelHeight], ([$height, $panelHeight]) => ($height - $panelHeight));
8 |
9 | export const margin = derived(panelHeight, ($panelHeight) => ({
10 | top: $panelHeight / 2.5,
11 | right: 100,
12 | bottom: 0,
13 | left: 100
14 | }));
15 |
16 | export const minDim = derived([width, panelHeight], ([$width, $panelHeight]) => $width > $panelHeight ? $panelHeight: $width);
17 | export const maxDim = derived([width, panelHeight], ([$width, $panelHeight]) => $width > $panelHeight ? $width : $panelHeight);
18 |
19 | export const controlsHeight = writable();
20 |
--------------------------------------------------------------------------------
/src/stores/elements.js:
--------------------------------------------------------------------------------
1 | import { writable } from 'svelte/store';
2 |
3 | export const controlsWrapper = writable();
4 | export const drawWrapper = writable();
5 |
--------------------------------------------------------------------------------
/src/stores/eventSelections.js:
--------------------------------------------------------------------------------
1 | import { writable, readable, derived } from 'svelte/store';
2 |
3 | function createSelected() {
4 | const { subscribe, set, update } = writable([]);
5 |
6 | return {
7 | subscribe,
8 | add: (obj) => update((f) => !f.map((d) => d.id).includes(obj.id) ? [...f, obj] : f),
9 | remove: (obj) => update((f) => f.filter((d) => d.id !== obj.id)),
10 | reset: () => set([])
11 | };
12 | }
13 |
14 | export const hovered = writable(null);
15 | export const selected = createSelected([]);
16 |
17 | export const tooltip = derived([hovered, selected], ([$hovered, $selected]) => {
18 | // if ($selected && $selected.length > 0) return $selected.slice(-1)[0];
19 | return $hovered;
20 | });
21 |
--------------------------------------------------------------------------------
/src/stores/filters.js:
--------------------------------------------------------------------------------
1 | import { writable } from 'svelte/store';
2 | import { uniq } from 'lodash';
3 |
4 | function createRangeFilter() {
5 | const { subscribe, set, update } = writable([0, 0]);
6 |
7 | return {
8 | subscribe,
9 | set,
10 | setMin: (value) => update((f) => f[0] = value),
11 | setMax: (value) => update((f) => f[1] = value)
12 | };
13 | }
14 |
15 | function createInclusiveFilter() {
16 | const { subscribe, set, update } = writable([]);
17 |
18 | const select = (id) => update((f) => f.map((d) => ({...d, selected: [id].flat().includes(d.id) ? true : d.selected})));
19 | const unselectAll = () => update((f) => f.map((d) => ({...d, selected: false})));
20 |
21 | const applyBoolArray = (arr) => {
22 | const tmpArr = [...arr].reverse();
23 | update((f) => f.reverse().map((d, i) => ({...d, selected: tmpArr[i] !== undefined ? tmpArr[i] : false})).reverse());
24 | };
25 |
26 | return {
27 | subscribe,
28 | set: (value) => set(value),
29 | init: (values, id) => set(uniq(values.map((d) => d[id]).flat()).map((id) => ({id, name: id, selected: true}))),
30 | select,
31 | selectOne: (id) => {
32 | unselectAll();
33 | select(id)
34 | },
35 | selectAll: () => update((f) => f.map((d) => ({...d, selected: true}))),
36 | unselect: (id) => update((f) => f.map((d) => ({...d, selected: [id].flat().includes(d.id) ? false : d.selected}))),
37 | unselectAll,
38 | applyBoolArray
39 | };
40 | }
41 |
42 | function createTextSearchFilter() {
43 | const { subscribe, set } = writable('');
44 |
45 | return {
46 | subscribe,
47 | set,
48 | reset: () => set('')
49 | };
50 | }
51 |
52 | export const disinformantNationFilter = createInclusiveFilter();
53 |
54 | export const platformFilter = createInclusiveFilter();
55 |
56 | export const methodFilter = createInclusiveFilter();
57 |
58 | export const sourceFilter = createInclusiveFilter();
59 |
60 | export const sourceCategoryFilter = createInclusiveFilter();
61 |
62 | export const tagFilter = createInclusiveFilter();
63 |
64 | export const attributionScoreFilter = createRangeFilter();
65 | export const attributionScoreDef = [0, 18];
66 |
67 | export const polarizationFilter = createRangeFilter();
68 | export const polarizationDef = [-2, 2];
69 |
70 | export const unselectAllFilters = (disinformantNation = true) => {
71 | if (disinformantNation) disinformantNationFilter.unselectAll();
72 | platformFilter.unselectAll();
73 | methodFilter.unselectAll();
74 | sourceFilter.unselectAll();
75 | sourceCategoryFilter.unselectAll();
76 | tagFilter.unselectAll();
77 | attributionScoreFilter.set(attributionScoreDef);
78 | polarizationFilter.set(polarizationDef);
79 | };
80 |
81 | export const selectAllFilters = (disinformantNation = true) => {
82 | if (disinformantNation) disinformantNationFilter.selectAll();
83 | platformFilter.selectAll();
84 | methodFilter.selectAll();
85 | sourceFilter.selectAll();
86 | sourceCategoryFilter.selectAll();
87 | tagFilter.selectAll();
88 | attributionScoreFilter.set(attributionScoreDef);
89 | polarizationFilter.set(polarizationDef);
90 | textSearchFilter.reset();
91 | caseIdFilter.set(undefined);
92 | };
93 |
94 | export const textSearchFilter = createTextSearchFilter();
95 |
96 | export const contextData = createInclusiveFilter();
97 |
98 | export const brushed = writable(false);
99 | export const originalTimeDomain = writable(null);
100 |
101 | export const caseIdFilter = writable();
102 |
103 | export const highlightPolarization = writable(false);
104 |
105 | export const highlightCib = writable(false);
106 |
--------------------------------------------------------------------------------
/src/stores/map.js:
--------------------------------------------------------------------------------
1 | import { writable, derived } from 'svelte/store';
2 | import { width, mapHeight } from './dimensions';
3 | import {
4 | geoMercator,
5 | geoPath as d3geoPath,
6 | min,
7 | max } from 'd3';
8 |
9 | const offsetFactor = 1.2;
10 |
11 | export const countries = writable([]);
12 |
13 | export const projection = derived([width, mapHeight, countries],
14 | ([$width, $mapHeight, $countries]) => {
15 | if ($countries.length === 0) return;
16 |
17 | const unitProjection = geoMercator()
18 | .scale(1)
19 | .translate([0, 0]);
20 |
21 | const tmpPath = d3geoPath().projection(unitProjection);
22 |
23 | const allBounds = $countries.map(tmpPath.bounds);
24 | const bounds = [[min(allBounds, (d) => d[0][0]), min(allBounds, (d) => d[0][1])],
25 | [max(allBounds, (d) => d[1][0]), max(allBounds, (d) => d[1][1])]];
26 |
27 | const scale = .95 / Math.max((bounds[1][0] - bounds[0][0]) / $width, (bounds[1][1] - bounds[0][1]) / $mapHeight / offsetFactor);
28 | const offset = [($width - scale * (bounds[1][0] + bounds[0][0])) / 2, ($mapHeight / offsetFactor - scale * (bounds[1][1] + bounds[0][1])) / 2];
29 |
30 | return(
31 | unitProjection
32 | .translate(offset)
33 | .scale(scale)
34 | );
35 | });
36 |
37 | export const geoPath = derived(projection, ($projection) => d3geoPath().projection($projection));
38 |
--------------------------------------------------------------------------------
/src/stores/scales.js:
--------------------------------------------------------------------------------
1 | import { writable, readable } from 'svelte/store';
2 |
3 | export const timeScale = writable();
4 | export const smiTotalYScale = writable();
5 | export const smiTotalRScale = writable();
6 | export const smiShareRScale = writable();
7 | export const attributionScoreScale = writable();
8 |
9 | export const centroidScale = writable();
10 |
11 | export const polarizationScale = writable();
12 |
13 | export const scaleFactor = readable(window.devicePixelRatio || 1);
14 |
--------------------------------------------------------------------------------
/src/transitions/constants.js:
--------------------------------------------------------------------------------
1 | export const bloomDuration = 200;
2 | export const growDuration = 700;
3 | export const jitterFactor = 10;
4 |
--------------------------------------------------------------------------------
/src/transitions/tween.js:
--------------------------------------------------------------------------------
1 | import { tweened } from 'svelte/motion';
2 | import { cubicInOut } from 'svelte/easing';
3 |
4 | export const createTweenedPos = () => tweened(undefined, {
5 | delay: 0,
6 | duration: 800,
7 | easing: cubicInOut
8 | });
9 |
--------------------------------------------------------------------------------
/src/utils/colors.js:
--------------------------------------------------------------------------------
1 | export const bg = '#F9F8F8';
2 | export const usaBlue = '#3c3b6e';
3 | export const usaRed = '#b22234';
4 | export const usaLightRed = '#b22234';
5 | export const usaLightLightRed = '#dbb6b6';
6 |
7 | export const polBlue = '#2e64a0';
8 | export const polLightBlue = '#61a3de';
9 | export const polPurple = '#96659e';
10 | export const polLightRed = '#a15552';
11 | export const polRed = '#ca0800';
12 |
--------------------------------------------------------------------------------
/src/utils/loadCoronaData.js:
--------------------------------------------------------------------------------
1 | import { csv, timeParse } from 'd3';
2 | import { sortConsistently } from './misc';
3 | import { coronaData } from '../inputs/dataPaths';
4 |
5 | const parseDate = timeParse('%Y-%m-%d');
6 |
7 | const loadCoronaData = async () => {
8 | const data = (await csv(coronaData, (d, i) => {
9 | return {
10 | id: i,
11 | date: parseDate(d.date),
12 | cases: +d.cases,
13 | deaths: +d.deaths
14 | };
15 | }))
16 | .sort((a, b) => -sortConsistently(a, b, 'date', 'id'));
17 |
18 | return(data);
19 | };
20 |
21 | export default loadCoronaData;
22 |
--------------------------------------------------------------------------------
/src/utils/loadData.js:
--------------------------------------------------------------------------------
1 | import { csv, timeParse } from 'd3';
2 | import { splitString, cleanCountries } from './misc';
3 | import { data as dataPath } from '../inputs/dataPaths';
4 |
5 | const parseTimestamp = timeParse('%m/%d/%Y %H:%M %Z');
6 | const parseDate = timeParse('%m/%d/%Y');
7 |
8 | const loadData = async () => {
9 | const data = await csv(dataPath, (d, i) => {
10 |
11 | const smiTotal = d.total_engagement === '' ? Number.NaN : +d.total_engagement;
12 | const smiPending = isNaN(smiTotal);
13 | const source = d.source_for_display !== '' ? d.source_for_display : d.source;
14 |
15 | const allSidesArticleCount = ((+d.allsides_count_left) + (+d.allsides_count_leanleft) + (+d.allsides_count_center) + (+d.allsides_count_leanright) + (+d.allsides_count_right));
16 |
17 | return {
18 | id: i,
19 | timestamp: parseTimestamp([d.timestamp, '-0400'].join(' ')),
20 | caseHash: d.case_hash,
21 | caseId: d.case_id,
22 | source: splitString(source),
23 | sourceFilter: splitString(d.source_subcategory !== '' ? d.source_subcategory : source),
24 | sourceSubcategory: splitString(d.source_subcategory),
25 | sourceCategory: splitString(d.source_category),
26 | sourceNation: d.source_nation,
27 | disinformant: d.disinformant,
28 | disinformantNation: cleanCountries(splitString(d.disinformant_nation)),
29 | disinformantAttribution: d.disinformant_attribution,
30 | shortTitle: d.short_title,
31 | shortDescription: d.short_description,
32 | attributionUrl: d.attribution_url,
33 | attributionArchiveUrl: d.archived_attribution_url,
34 | imageUrl: d.image_url,
35 | imageCredit: d.image_credit,
36 | openWeb: d.open_web,
37 | socialMedia: d.social_media,
38 | messagingPlatforms: d.messaging_platforms,
39 | other: d.other,
40 | platforms: splitString([d.social_media, d.messaging_platforms].join(',')),
41 | startDate: parseDate(d.start_date),
42 | endDate: parseDate(d.end_date),
43 | attributionDate: parseDate(d.attribution_date),
44 | methods: splitString(d.methods),
45 | smiTotal,
46 | smiFacebook: d.facebook_engagement === '' ? Number.NaN : +d.facebook_engagement,
47 | smiTwitter: d.twitter_engagement === '' ? Number.NaN : +d.twitter_engagement,
48 | smiReddit: d.reddit_engagement === '' ? Number.NaN : +d.reddit_engagement,
49 | smiFacebookShare: ((+d.facebook_engagement) / smiTotal) || 0,
50 | smiTwitterShare: ((+d.twitter_engagement) / smiTotal) || 0,
51 | smiRedditShare: ((+d.reddit_engagement) / smiTotal) || 0,
52 | smiPending,
53 | attributionScore: +d.attribution_total_score,
54 | attributionCredibilityScore: +d.attribution_credibility_score,
55 | attributionObjectivityScore: +d.attribution_objectivity_score,
56 | attributionEvidenceScore: +d.attribution_evidence_score,
57 | attributionTransparencyScore: +d.attribution_transparency_score,
58 | attribution_financial_incentive: +d.attribution_financial_incentive,
59 | attribution_financial_transparency: +d.attribution_financial_transparency,
60 | attribution_endorse_political: +d.attribution_endorse_political,
61 | attribution_campaign_affiliation: +d.attribution_campaign_affiliation,
62 | attribution_published_mis_disinfo: +d.attribution_published_mis_disinfo,
63 | attribution_language: +d.attribution_language,
64 | attribution_convey_content: +d.attribution_convey_content,
65 | attribution_factual_argument: +d.attribution_factual_argument,
66 | attribution_clarity: +d.attribution_clarity,
67 | attribution_context: +d.attribution_context,
68 | attribution_identified_responsibility: +d.attribution_identified_responsibility,
69 | attribution_strategic_rationale: +d.attribution_strategic_rationale,
70 | attribution_privileged_evidence: +d.attribution_privileged_evidence,
71 | attribution_open_access: +d.attribution_open_access,
72 | attribution_methodology: +d.attribution_methodology,
73 | attribution_open_source: +d.attribution_open_source,
74 | attribution_acknowledge_limitations: +d.attribution_acknowledge_limitations,
75 | attribution_corroboration: +d.attribution_corroboration,
76 | tags: splitString(d.tags),
77 | articleCount: +d.articleCount,
78 | polarization: {
79 | fulfills10Articles: allSidesArticleCount >= 10,
80 | fulfills25Percent: allSidesArticleCount / (+d.article_count) >= 0.25,
81 | count: {
82 | l: +d.allsides_count_left,
83 | ll: +d.allsides_count_leanleft,
84 | c: +d.allsides_count_center,
85 | lr: +d.allsides_count_leanright,
86 | r: +d.allsides_count_right
87 | },
88 | general: {
89 | l: +d.allsides_engagement_left,
90 | ll: +d.allsides_engagments_leanleft,
91 | c: +d.allsides_engagments_center,
92 | lr: +d.allsides_engagments_leanright,
93 | r: +d.allsides_engagments_right
94 | },
95 | facebook: {
96 | l: +d.allsides_engagments_left_facebook,
97 | ll: +d.allsides_engagments_leanleft_facebook,
98 | c: +d.allsides_engagments_center_facebook,
99 | lr: +d.allsides_engagments_leanright_facebook,
100 | r: +d.allsides_engagments_right_facebook
101 | },
102 | twitter: {
103 | l: +d.allsides_engagments_left_twitter,
104 | ll: +d.allsides_engagments_leanleft_twitter,
105 | c: +d.allsides_engagments_center_twitter,
106 | lr: +d.allsides_engagments_leanright_twitter,
107 | r: +d.allsides_engagments_right_twitter
108 | },
109 | reddit: {
110 | l: +d.allsides_engagments_left_reddit,
111 | ll: +d.allsides_engagments_leanleft_reddit,
112 | c: +d.allsides_engagments_center_reddit,
113 | lr: +d.allsides_engagments_leanright_reddit,
114 | r: +d.allsides_engagments_right_reddit
115 | }
116 | },
117 | cib: {
118 | hasCib: +d.cases > 0,
119 | entryDate: parseDate(d.entry_date),
120 | announcedDate: parseDate(d.announced_date),
121 | url: d.url,
122 | pagesTotalFb: +d.fb_pages_total,
123 | budgetTotalUsd: +d.budget_usd_total,
124 | accountsTotalFb: +d.fb_accounts_total,
125 | pagesFollowersTotalFb: +d.fb_pages_followers_total,
126 | groupsTotalFb: +d.fb_groups_total,
127 | groupsFollowersTotalFb: +d.fb_groups_followers_total,
128 | eventsTotal: +d.Events_total,
129 | accountsTotalIg: +d.ig_accounts_total,
130 | followersTotalIg: +d.ig_followers_total,
131 | cases: +d.cases
132 | }
133 | };
134 | });
135 |
136 | return(data.filter((d) => d.timestamp !== null));
137 | };
138 |
139 | export default loadData;
140 |
--------------------------------------------------------------------------------
/src/utils/loadGoogleTrendsData.js:
--------------------------------------------------------------------------------
1 | import { googleTrendsApiPath } from '../inputs/dataPaths';
2 |
3 | const loadGoogleTrendsData = async (keyword) => {
4 | let data = [];
5 | try {
6 | const response = await fetch(`${googleTrendsApiPath}${encodeURIComponent(keyword)}`);
7 | data = (await response.json()).map((d) => ({
8 | ...d,
9 | date: new Date(d.time * 1000)
10 | }))
11 | .filter((d) => !d.isPartial);
12 | } finally {
13 | return(data);
14 | }
15 | };
16 |
17 | export default loadGoogleTrendsData;
18 |
--------------------------------------------------------------------------------
/src/utils/loadMapData.js:
--------------------------------------------------------------------------------
1 | import { json } from 'd3';
2 | import { countries } from '../stores/map';
3 | import { feature } from 'topojson';
4 |
5 | const dataPath = 'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-50m.json';
6 |
7 | const loadMapData = async () => {
8 | const world = await json(dataPath);
9 |
10 | countries.set(feature(world, world.objects.countries)
11 | .features
12 | .filter((d) => d.properties.name !== 'Antarctica'));
13 | };
14 |
15 | export default loadMapData;
16 |
--------------------------------------------------------------------------------
/src/utils/loadSpots.js:
--------------------------------------------------------------------------------
1 | import { csv, timeParse } from 'd3';
2 | import { spotData } from '../inputs/dataPaths';
3 |
4 | const parseDate = timeParse('%m/%d/%Y');
5 |
6 | const loadSpots = async () => {
7 | const data = await csv(spotData, (d, i) => {
8 | return {
9 | id: i,
10 | name: d.event_name,
11 | date: parseDate(d.date),
12 | description: d.description
13 | };
14 | });
15 |
16 | return(data);
17 | };
18 |
19 | export default loadSpots;
20 |
--------------------------------------------------------------------------------
/src/utils/misc.js:
--------------------------------------------------------------------------------
1 | import { uniq } from 'lodash';
2 | import { mean, min, max } from 'd3';
3 | import { images } from '../inputs/dataPaths';
4 | import { categories } from '../inputs/polarization';
5 |
6 | // extract attribution date range from data
7 | export const getTimeRange = (data) => {
8 | const maxAttributionDate = max(data, (d) => d.attributionDate);
9 | return([min(data, (d) => d.attributionDate), new Date(
10 | maxAttributionDate.getFullYear(), maxAttributionDate.getMonth() + 5
11 | )]);
12 | };
13 |
14 | // preload images
15 | export const preloadImages = (data) => {
16 | data.forEach((d) => (new Image()).src = `${images}${d.caseHash}.jpg`);
17 | }
18 |
19 | // split string in array
20 | export const splitString = (s) => {
21 | if (s === '' || s === ',') return ['unspecified'];
22 | return(s
23 | .split(',')
24 | .map((d) => d.trim())
25 | .filter((d) => d !== ''));
26 | };
27 |
28 | // clean countries array
29 | export const cleanCountries = (countries) =>
30 | countries
31 | .map((c) =>
32 | c
33 | .trim()
34 | .replace(/^US$/, 'United States of America')
35 | .replace(/^United States$/, 'United States of America')
36 | );
37 |
38 | // check, if there's an overlap between array and filter
39 | export const haveOverlap = (filter, arr) =>
40 | filter.filter((d) => d.selected).map((d) => d.id).some((item) => arr.includes(item));
41 |
42 | // check, if a number is within a 2D range (given as array with length 2)
43 | export const withinRange = (arr, num, bypass = false) => bypass ? true : (num >= arr[0] && num <= arr[1]);
44 |
45 | // check, if a search string (filter) is included in a string
46 | export const includesTextSearch = (filter, s) => {
47 | const filterArr = filter.toLowerCase().split(' or ');
48 | if (filterArr.length === 0) return true;
49 | return filterArr.some((f) => {
50 | return s.indexOf(f) > -1;
51 | });
52 | };
53 |
54 | // check if case id filter is set and if id is matching
55 | export const isCaseId = (filter, id) => filter === undefined ? true : (filter === id);
56 |
57 | // check, if polarization data can be shown
58 | export const showPolarization = (filter, polarization) => {
59 | if (!filter) return(true);
60 | return(polarization.fulfills10Articles || polarization.fulfills25Percent);
61 | };
62 |
63 | // check, if cib data can be shown
64 | export const showCib = (filter, cib) => {
65 | if (!filter) return(true);
66 | return(cib.hasCib);
67 | };
68 |
69 | // extract filter items from data
70 | export const extractFilterCategories = (data, name) =>
71 | uniq(data.map((d) => d[name]).flat());
72 |
73 | // functions to compute density
74 | // https://www.d3-graph-gallery.com/graph/density_basic.html
75 | export const kernelEpanechnikov = (k) => (v) =>
76 | Math.abs((v /= k)) <= 1 ? (0.75 * (1 - v * v)) / k : 0;
77 |
78 | export const kernelDensityEstimator = (kernel, X) => (V) =>
79 | X.map((x) => [
80 | x,
81 | mean(V, function (v) {
82 | return kernel(x - v);
83 | }),
84 | ]);
85 |
86 | // extract host name from URL
87 | // https://stackoverflow.com/questions/8498592/extract-hostname-name-from-string
88 | export const extractHostname = (url) => {
89 | let hostname = url.indexOf('//') > -1 ? url.split('/')[2] : url.split('/')[0];
90 | hostname = hostname.split(':')[0];
91 | hostname = hostname.split('?')[0];
92 | return hostname;
93 | };
94 |
95 | // consistent sort function
96 | export const sortConsistently = (itemA, itemB, property, key) => {
97 | let valueA = itemA[property];
98 | let valueB = itemB[property];
99 |
100 | if (typeof valueA === 'string') valueA = valueA.trim().toLowerCase();
101 | if (typeof valueB === 'string') valueB = valueB.trim().toLowerCase();
102 | if (typeof valueA === 'number') valueA = +valueA;
103 | if (typeof valueB === 'number') valueB = +valueB;
104 | if (typeof valueA === 'number' && isNaN(valueA)) valueA = 0;
105 | if (typeof valueB === 'number' && isNaN(valueB)) valueB = 0;
106 |
107 | let r = valueA > valueB ? -1 : valueA < valueB ? 1 : 0;
108 |
109 | if (r === 0) {
110 | r = typeof itemA[key] !== 'undefined' && typeof itemB[key] !== 'undefined'
111 | ? +itemA[key] - +itemB[key]
112 | : 0;
113 | }
114 | return r;
115 | };
116 |
117 | // scroll-to function (also set on window to make it available outside svelte)
118 | export const scrollTo = (targetId, collapsibleId) => {
119 | if (collapsibleId) {
120 | document.getElementById(collapsibleId).checked = true;
121 | }
122 |
123 | document.querySelector('.draw-wrapper').classList.add('no-pointer-events');
124 |
125 | setTimeout(() => {
126 | document.getElementById(targetId).scrollIntoView({behavior: 'smooth'});
127 | setTimeout(() => {
128 | document.querySelector('.draw-wrapper').classList.remove('no-pointer-events');
129 | }, 1000);
130 | }, 200);
131 | return(false);
132 | };
133 | window.scrollsmooth = scrollTo;
134 |
135 | // calculate average polarization using weights
136 | export const calculateAveragePolarization = (polarization) => {
137 | const weightedEngagement = Object.keys(polarization).map((id) => {
138 | const weight = categories.find((c) => c.id === id).weight;
139 | return(weight * polarization[id]);
140 | })
141 | .reduce((acc, cur) => acc + cur);
142 |
143 | const totalEngagement = Object.keys(polarization).map((id) => polarization[id]).reduce((acc, cur) => acc + cur);
144 |
145 | return(weightedEngagement / totalEngagement);
146 | };
147 |
--------------------------------------------------------------------------------
/src/utils/paths.js:
--------------------------------------------------------------------------------
1 | import { oneLineTrim } from 'common-tags';
2 | import { line, curveBasis } from 'd3';
3 |
4 | // just a line with D3 curviness
5 | const lineGenerator = line().curve(curveBasis);
6 |
7 | // a simple curved line with two endpoints
8 | export const curvyLine = (x1, y1, x2, y2) => {
9 | return(oneLineTrim`
10 | M${x1} ${y1}
11 | C${x1} ${(y2 - y1) / 2 + y1},
12 | ${x2} ${(y2 - y1) / 2 + y1},
13 | ${x2} ${y2}`);
14 | };
15 |
16 | // a double curved line with three handle points (used for the source links)
17 | export const curvyDoubleLine = (x1, y1, x2, y2, x3, y3, shift, yOffset) => {
18 | if (!x1 || !y1) return(curvyLine(x2, y2, x3, y3));
19 | const yMulti = x1 < x2 ? -1 : 1;
20 | const lineData = [
21 | [x1, y1],
22 | [x1 + shift, y1 - yOffset / 2],
23 | [x1 + shift * 2, y1 - yOffset - shift * yMulti],
24 | [x1 + shift * 2, y1 - 2 * yOffset - shift * 2 * yMulti],
25 | [x2, Math.max(y1 - 2 * yOffset - shift * 2 * yMulti, y2)],
26 | [x2, Math.max(y1 - 3 * yOffset - shift * 2 * yMulti, y2)],
27 | [x2, y2]
28 | ];
29 | return(lineGenerator(lineData) + oneLineTrim`
30 | C${x2} ${(y3 - y2) / 2 + y2},
31 | ${x3} ${(y3 - y2) / 2 + y2},
32 | ${x3} ${y3}`);
33 | };
34 |
35 | // a soft straight line with diminishing end points (used for the timeline)
36 | export const gentleLine = (width, xOffset, yOffset) => {
37 | return(oneLineTrim`M0 0
38 | C${xOffset / 2} 0,
39 | ${xOffset / 2} ${yOffset},
40 | ${xOffset} ${yOffset}
41 | L${width - xOffset} ${yOffset}
42 | C${width - xOffset / 2} ${yOffset},
43 | ${width - xOffset / 2} 0,
44 | ${width} 0
45 | C${width - xOffset / 2} 0,
46 | ${width - xOffset / 2} ${-yOffset},
47 | ${width - xOffset} ${-yOffset}
48 | L${xOffset} ${-yOffset}
49 | C${xOffset / 2} ${-yOffset},
50 | ${xOffset / 2} 0,
51 | 0 0
52 | Z`);
53 | }
54 |
--------------------------------------------------------------------------------
/src/utils/scales.js:
--------------------------------------------------------------------------------
1 | import {
2 | timeScale,
3 | smiTotalYScale,
4 | smiTotalRScale,
5 | smiShareRScale,
6 | attributionScoreScale,
7 | centroidScale,
8 | polarizationScale } from '../stores/scales';
9 | import {
10 | usaRed,
11 | polBlue,
12 | polLightBlue,
13 | polPurple,
14 | polLightRed,
15 | polRed } from '../utils/colors';
16 | import {
17 | scaleTime,
18 | scaleLinear,
19 | scaleSqrt,
20 | scaleLog,
21 | max,
22 | group } from 'd3';
23 | import { getTimeRange } from '../utils/misc';
24 |
25 | // sets all the basic scales
26 | export const setScales = (data, width, minDim, maxDim, panelHeight, margin) => {
27 | if (!data) return;
28 |
29 | // time scale
30 | timeScale.set(scaleTime()
31 | .domain(getTimeRange(data))
32 | .range([margin.left, width - margin.right]));
33 |
34 | // smi total scale for the y axis
35 | smiTotalYScale.set(scaleLog()
36 | .domain([10, max(data, (d) => d.smiTotal)])
37 | .range([panelHeight - margin.bottom, margin.top]));
38 |
39 | // smi total scale for the radii
40 | smiTotalRScale.set(scaleSqrt()
41 | .domain([0, max(data, (d) => d.smiTotal)])
42 | .range([width * 0.009, width * 0.04]));
43 |
44 | // smi share scale for the radii
45 | smiShareRScale.set(scaleLinear()
46 | .domain([0, 1])
47 | .range([0, minDim * 0.15]));
48 |
49 | // attribution score scale
50 | attributionScoreScale.set(scaleLinear()
51 | .domain([-1, 1.1 * max(data, (d) => d.attributionScore)])
52 | .range(['#FFFFFF', usaRed]));
53 |
54 | // centroid scale
55 | const casesPerCountry = [...group(data.map((d) => d.disinformantNation).flat(), (d) => d)].map((d) => d[1].length);
56 |
57 | centroidScale.set(scaleSqrt()
58 | .domain([0, max(casesPerCountry)])
59 | .range([maxDim * 0.0005, maxDim * 0.01]));
60 |
61 | // polarization scale
62 | polarizationScale.set(scaleLinear()
63 | .domain([-2, 0, 2])
64 | .range([polBlue, polPurple, polRed]));
65 | };
66 |
--------------------------------------------------------------------------------
/src/utils/share.js:
--------------------------------------------------------------------------------
1 | export const baseUrl = 'https://interference2020.org';
2 | // export const baseUrl = 'http://localhost:5000';
3 |
4 | export const urlFromFilters = (disinformantNations,
5 | platforms,
6 | methods,
7 | sources,
8 | sourceCategories,
9 | tags,
10 | attributionScores,
11 | polarization,
12 | textSearch,
13 | contextData,
14 | caseId = '',
15 | highlightPolarization,
16 | highlightCib) => {
17 | const params = {
18 | ts: encodeURIComponent(textSearch),
19 | as: [attributionScores[0], attributionScores[1]].join(';'),
20 | pol: [Math.round(100 * polarization[0]) / 100, Math.round(100 * polarization[1]) / 100].join(';'),
21 | f: filtersToHex([disinformantNations, platforms, methods, sources, sourceCategories, tags, contextData]),
22 | id: caseId,
23 | bool: filtersToBin([highlightPolarization, highlightCib])
24 | };
25 |
26 | return `${baseUrl}/#${params.f}&${params.id}&${params.ts}&${params.as}&${params.pol}&${params.bool}`;
27 | };
28 |
29 | export const filtersToHex = (arr) => {
30 | const hex = arr.map((d) => binaryToHex(d.map((d) => +d.selected).join(''))).join('&');
31 | return hex;
32 | };
33 |
34 | export const filtersToBin = (arr) => {
35 | const bin = arr.map((d) => d ? 1 : 0).join('');
36 | return bin;
37 | };
38 |
39 | export const binaryToHex = (binary) => parseInt(binary , 2).toString(16).toLowerCase();
40 |
41 | export const hexToBinary = (hex) => parseInt(hex, 16).toString(2);
42 |
43 | export const binaryToBool = (binary) => binary.split('').map((d) => d === '0' ? false : true);
44 |
45 | export const parseUrl = (hash) => {
46 | const s = hash.substring(1);
47 | const [ disinformantNations, platforms, methods, sources, sourceCategories, tags, contextData, caseId, textSearch, attributionScores, polarization, bools] = s.split('&');
48 |
49 | const boolArray = bools.split('').map((d) => +d === 1 ? true : false);
50 |
51 | return {
52 | disinformantNations: binaryToBool(hexToBinary(disinformantNations)),
53 | platforms: binaryToBool(hexToBinary(platforms)),
54 | methods: binaryToBool(hexToBinary(methods)),
55 | sources: binaryToBool(hexToBinary(sources)),
56 | sourceCategories: binaryToBool(hexToBinary(sourceCategories)),
57 | tags: binaryToBool(hexToBinary(tags)),
58 | contextData: binaryToBool(hexToBinary(contextData)),
59 | caseId: caseId === '' ? undefined : +caseId,
60 | textSearch: decodeURIComponent(textSearch),
61 | attributionScores: attributionScores.split(';').map((d) => +d),
62 | polarization: polarization.split(';').map((d) => +d),
63 | highlightPolarization: boolArray[0],
64 | highlightCib: boolArray[1]
65 | };
66 | };
67 |
--------------------------------------------------------------------------------