share buttons, share URLs
Этот коммит содержится в:
родитель
16e2f3877b
Коммит
562414925e
9
package-lock.json
сгенерированный
9
package-lock.json
сгенерированный
@ -5393,6 +5393,15 @@
|
||||
"integrity": "sha512-OX/IBVUJSFo1rnznXdwf9rv6LReJ3qQ0PwRjj76vfUWyTfbHbR9OXqJBnUrpjyis2dwYcbT2Zm1DFjOOF1ZbbQ==",
|
||||
"dev": true
|
||||
},
|
||||
"svelte-awesome": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/svelte-awesome/-/svelte-awesome-2.3.0.tgz",
|
||||
"integrity": "sha512-xXU3XYJmr5PK9e3pCpW6feU/tNpAhERWDrgDxkC0DU6LNum02zzc00I17bmUWTSRdKLf+IFbA5hWvCm1V8g+/A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"svelte": "^3.15.0"
|
||||
}
|
||||
},
|
||||
"svg-path-properties": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/svg-path-properties/-/svg-path-properties-0.2.2.tgz",
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
"rollup-plugin-svelte": "^5.0.3",
|
||||
"rollup-plugin-terser": "^7.0.0",
|
||||
"svelte": "^3.0.0",
|
||||
"svelte-awesome": "^2.3.0",
|
||||
"topojson": "^3.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@ -1,23 +1,10 @@
|
||||
<script>
|
||||
// loads data for context datasets and holds the individual graphing components
|
||||
import { onMount } from 'svelte';
|
||||
import loadCoronaData from '../utils/loadCoronaData';
|
||||
import { contextData, originalTimeDomain } from '../stores/filters';
|
||||
import { margin } from '../stores/dimensions';
|
||||
|
||||
import CoronaChart from './CoronaChart.svelte';
|
||||
|
||||
onMount(async () => {
|
||||
$contextData = [
|
||||
{
|
||||
id: 'corona',
|
||||
name: 'COVID-19 in the US',
|
||||
source: 'The New York Times',
|
||||
data: await loadCoronaData(),
|
||||
selected: false
|
||||
}
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<g class="background-chart">
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
import Dropdown from './Dropdown.svelte';
|
||||
import Slider from './Slider.svelte';
|
||||
import SearchText from './SearchText.svelte';
|
||||
import Share from './Share.svelte';
|
||||
|
||||
export let timePoints;
|
||||
|
||||
@ -40,65 +41,72 @@
|
||||
</script>
|
||||
|
||||
{#if (timePoints)}
|
||||
<div class="controls">
|
||||
<SearchText searchString={$textSearchFilter}
|
||||
label="Search"
|
||||
on:change={(e) => $textSearchFilter = e.detail}
|
||||
on:reset={() => textSearchFilter.reset()} />
|
||||
<Slider value={$attributionScoreFilter}
|
||||
label="Attribution Score"
|
||||
min={attributionScoreDef[0]}
|
||||
max={attributionScoreDef[1]}
|
||||
showHandleLabels={false}
|
||||
startColor={$attributionScoreScale(attributionScoreDef[0])}
|
||||
stopColor={$attributionScoreScale(attributionScoreDef[1])}
|
||||
on:changed={(e) => $attributionScoreFilter = e.detail} />
|
||||
<Dropdown items={addCount($disinformantNationFilter, 'disinformantNation', timePoints)}
|
||||
label="Disinformant Nation"
|
||||
superior
|
||||
on:itemsAdded={(e) => disinformantNationFilter.select(e.detail)}
|
||||
on:itemsRemoved={(e) => disinformantNationFilter.unselect(e.detail)} />
|
||||
<Dropdown items={addCount($platformFilter, 'platforms', timePoints)}
|
||||
label="Platform"
|
||||
on:itemsAdded={(e) => platformFilter.select(e.detail)}
|
||||
on:itemsRemoved={(e) => platformFilter.unselect(e.detail)} />
|
||||
<Dropdown items={addCount($sourceFilter, 'sourceFilter', timePoints)}
|
||||
label="Source"
|
||||
hideOneHitWonders
|
||||
superior
|
||||
on:itemsAdded={(e) => sourceFilter.select(e.detail)}
|
||||
on:itemsRemoved={(e) => sourceFilter.unselect(e.detail)} />
|
||||
<Dropdown items={addCount($sourceCategoryFilter, 'sourceCategory', timePoints)}
|
||||
label="Source Category"
|
||||
on:itemsAdded={(e) => sourceCategoryFilter.select(e.detail)}
|
||||
on:itemsRemoved={(e) => sourceCategoryFilter.unselect(e.detail)} />
|
||||
<Dropdown items={addCount($methodFilter, 'methods', timePoints)}
|
||||
label="Method"
|
||||
superior
|
||||
on:itemsAdded={(e) => methodFilter.select(e.detail)}
|
||||
on:itemsRemoved={(e) => methodFilter.unselect(e.detail)} />
|
||||
<Dropdown items={$contextData}
|
||||
label="Context Dataset"
|
||||
nameField="name"
|
||||
on:itemsAdded={(e) => contextData.select(e.detail)}
|
||||
on:itemsRemoved={(e) => contextData.unselect(e.detail)} />
|
||||
<button class="reset-filters"
|
||||
on:click={() => handleButtonClick()}>
|
||||
Reset
|
||||
</button>
|
||||
<div class="controls-inner-wrapper">
|
||||
<div class="controls">
|
||||
<SearchText searchString={$textSearchFilter}
|
||||
label="Search"
|
||||
on:change={(e) => $textSearchFilter = e.detail}
|
||||
on:reset={() => textSearchFilter.reset()} />
|
||||
<Slider value={$attributionScoreFilter}
|
||||
label="Attribution Score"
|
||||
min={attributionScoreDef[0]}
|
||||
max={attributionScoreDef[1]}
|
||||
showHandleLabels={false}
|
||||
startColor={$attributionScoreScale(attributionScoreDef[0])}
|
||||
stopColor={$attributionScoreScale(attributionScoreDef[1])}
|
||||
on:changed={(e) => $attributionScoreFilter = e.detail} />
|
||||
<Dropdown items={addCount($disinformantNationFilter, 'disinformantNation', timePoints)}
|
||||
label="Disinformant Nation"
|
||||
superior
|
||||
on:itemsAdded={(e) => disinformantNationFilter.select(e.detail)}
|
||||
on:itemsRemoved={(e) => disinformantNationFilter.unselect(e.detail)} />
|
||||
<Dropdown items={addCount($platformFilter, 'platforms', timePoints)}
|
||||
label="Platform"
|
||||
on:itemsAdded={(e) => platformFilter.select(e.detail)}
|
||||
on:itemsRemoved={(e) => platformFilter.unselect(e.detail)} />
|
||||
<Dropdown items={addCount($sourceFilter, 'sourceFilter', timePoints)}
|
||||
label="Source"
|
||||
hideOneHitWonders
|
||||
superior
|
||||
on:itemsAdded={(e) => sourceFilter.select(e.detail)}
|
||||
on:itemsRemoved={(e) => sourceFilter.unselect(e.detail)} />
|
||||
<Dropdown items={addCount($sourceCategoryFilter, 'sourceCategory', timePoints)}
|
||||
label="Source Category"
|
||||
on:itemsAdded={(e) => sourceCategoryFilter.select(e.detail)}
|
||||
on:itemsRemoved={(e) => sourceCategoryFilter.unselect(e.detail)} />
|
||||
<Dropdown items={addCount($methodFilter, 'methods', timePoints)}
|
||||
label="Method"
|
||||
superior
|
||||
on:itemsAdded={(e) => methodFilter.select(e.detail)}
|
||||
on:itemsRemoved={(e) => methodFilter.unselect(e.detail)} />
|
||||
<Dropdown items={$contextData}
|
||||
label="Context Dataset"
|
||||
nameField="name"
|
||||
on:itemsAdded={(e) => contextData.select(e.detail)}
|
||||
on:itemsRemoved={(e) => contextData.unselect(e.detail)} />
|
||||
<button class="reset-filters"
|
||||
on:click={() => handleButtonClick()}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<Share />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.controls-inner-wrapper {
|
||||
padding: 0.2rem;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background-color: var(--transparentbg);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(9, 1fr);
|
||||
grid-gap: 0.3rem;
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background-color: var(--transparentbg);
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
@media (min-width: 460px) {
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
import EventTooltipCross from './EventTooltipCross.svelte';
|
||||
import ScoreBar from './ScoreBar.svelte';
|
||||
import ScoreQuestions from './ScoreQuestions.svelte';
|
||||
import Share from './Share.svelte';
|
||||
|
||||
const offset = {
|
||||
top: 10,
|
||||
@ -123,23 +124,26 @@
|
||||
<div class="scroll-wrapper"
|
||||
bind:this={scrollWrapper}>
|
||||
<div class="title">
|
||||
<div class="title-dates">
|
||||
<p class="no-break">{attributionTf($tooltip.tp.attributionDate)} | {@html highlight($tooltip.tp.disinformantAttribution)}</p>
|
||||
<p>Active:
|
||||
{#if ($tooltip.tp.startDate && !$tooltip.tp.endDate)}
|
||||
{activityTf($tooltip.tp.startDate)} <span class="small">(approx.)</span>
|
||||
{:else if (!$tooltip.tp.startDate && $tooltip.tp.endDate)}
|
||||
{activityTf($tooltip.tp.endDate)} <span class="small">(approx.)</span>
|
||||
{:else if ($tooltip.tp.startDate && $tooltip.tp.endDate)}
|
||||
{#if (activityTf($tooltip.tp.startDate) === activityTf($tooltip.tp.endDate))}
|
||||
{activityTf($tooltip.tp.startDate)}
|
||||
<div class="title-top">
|
||||
<div class="title-dates">
|
||||
<p class="no-break">{attributionTf($tooltip.tp.attributionDate)} | {@html highlight($tooltip.tp.disinformantAttribution)}</p>
|
||||
<p>Active:
|
||||
{#if ($tooltip.tp.startDate && !$tooltip.tp.endDate)}
|
||||
{activityTf($tooltip.tp.startDate)} <span class="small">(approx.)</span>
|
||||
{:else if (!$tooltip.tp.startDate && $tooltip.tp.endDate)}
|
||||
{activityTf($tooltip.tp.endDate)} <span class="small">(approx.)</span>
|
||||
{:else if ($tooltip.tp.startDate && $tooltip.tp.endDate)}
|
||||
{#if (activityTf($tooltip.tp.startDate) === activityTf($tooltip.tp.endDate))}
|
||||
{activityTf($tooltip.tp.startDate)}
|
||||
{:else}
|
||||
{activityTf($tooltip.tp.startDate)} to {activityTf($tooltip.tp.endDate)}
|
||||
{/if}
|
||||
{:else}
|
||||
{activityTf($tooltip.tp.startDate)} to {activityTf($tooltip.tp.endDate)}
|
||||
unspecified
|
||||
{/if}
|
||||
{:else}
|
||||
unspecified
|
||||
{/if}
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
<Share text="" caseId={$tooltip.tp.id} mode="tooltip" />
|
||||
</div>
|
||||
<h2>{@html highlight($tooltip.tp.shortTitle)}</h2>
|
||||
<div class="score-bars">
|
||||
@ -288,6 +292,16 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title-dates {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.title-dates p {
|
||||
color: var(--text-black);
|
||||
font-size: 0.7rem;
|
||||
|
||||
83
src/components/Share.svelte
Обычный файл
83
src/components/Share.svelte
Обычный файл
@ -0,0 +1,83 @@
|
||||
<script>
|
||||
import {
|
||||
disinformantNationFilter,
|
||||
platformFilter,
|
||||
methodFilter,
|
||||
sourceFilter,
|
||||
sourceCategoryFilter,
|
||||
attributionScoreFilter,
|
||||
attributionScoreDef,
|
||||
textSearchFilter,
|
||||
originalTimeDomain,
|
||||
contextData } from '../stores/filters';
|
||||
import { urlFromFilters } from '../utils/share';
|
||||
|
||||
import Icon from 'svelte-awesome';
|
||||
import { twitter, clipboard } from 'svelte-awesome/icons';
|
||||
|
||||
export let text = 'Share this view.';
|
||||
export let caseId = '';
|
||||
export let mode = 'standard';
|
||||
|
||||
async function copyToClipBoard() {
|
||||
await navigator.clipboard.writeText(url);
|
||||
const previousText = text;
|
||||
text = 'Copied!';
|
||||
setTimeout(() => text = previousText, 3000);
|
||||
}
|
||||
|
||||
$: url = urlFromFilters(
|
||||
$disinformantNationFilter,
|
||||
$platformFilter,
|
||||
$methodFilter,
|
||||
$sourceFilter,
|
||||
$sourceCategoryFilter,
|
||||
$attributionScoreFilter,
|
||||
$textSearchFilter,
|
||||
$contextData,
|
||||
caseId);
|
||||
</script>
|
||||
|
||||
<div class="share">
|
||||
<p class:gray={mode === 'tooltip'}>{text}</p>
|
||||
<a class="twitter-share-button"
|
||||
class:gray={mode === 'tooltip'}
|
||||
href="https://twitter.com/intent/tweet?url={url.replace('#', '%23')}"
|
||||
data-size="large"
|
||||
target="_blank">
|
||||
<Icon data={twitter}/>
|
||||
</a>
|
||||
<span class="pseudolink"
|
||||
class:gray={mode === 'tooltip'}
|
||||
on:click={copyToClipBoard}>
|
||||
<Icon data={clipboard}/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.share {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-02);
|
||||
font-size: 0.7rem;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--usa-blue);
|
||||
}
|
||||
|
||||
a {
|
||||
margin: 0 0.4rem;
|
||||
}
|
||||
|
||||
.gray {
|
||||
color: var(--text-darkgray);
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
|
||||
.gray:hover {
|
||||
color: var(--text-black);
|
||||
}
|
||||
</style>
|
||||
@ -6,6 +6,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import loadData from '../utils/loadData';
|
||||
import loadMapData from '../utils/loadMapData';
|
||||
import loadCoronaData from '../utils/loadCoronaData';
|
||||
import { setScales } from '../utils/scales';
|
||||
import {
|
||||
width,
|
||||
@ -30,8 +31,10 @@
|
||||
attributionScoreFilter,
|
||||
attributionScoreDef,
|
||||
textSearchFilter,
|
||||
originalTimeDomain } from '../stores/filters';
|
||||
import { haveOverlap, withinRange, includesTextSearch, preloadImages } from '../utils/misc';
|
||||
originalTimeDomain,
|
||||
contextData,
|
||||
caseIdFilter } from '../stores/filters';
|
||||
import { haveOverlap, withinRange, includesTextSearch, isCaseId, preloadImages } from '../utils/misc';
|
||||
import { selected } from '../stores/eventSelections';
|
||||
import { drawWrapper } from '../stores/elements';
|
||||
|
||||
@ -46,6 +49,7 @@
|
||||
forceCollide,
|
||||
timeFormat } from 'd3';
|
||||
import { sortConsistently } from '../utils/misc';
|
||||
import { parseUrl } from '../utils/share';
|
||||
|
||||
import ToTop from './ToTop.svelte';
|
||||
import TopVisualContent from './TopVisualContent.svelte';
|
||||
@ -85,7 +89,33 @@
|
||||
sourceCategoryFilter.init(data, 'sourceCategory');
|
||||
$attributionScoreFilter = attributionScoreDef;
|
||||
|
||||
// get context datasets
|
||||
$contextData = [
|
||||
{
|
||||
id: 'corona',
|
||||
name: 'COVID-19 in the US',
|
||||
source: 'The New York Times',
|
||||
data: await loadCoronaData(),
|
||||
selected: false
|
||||
}
|
||||
];
|
||||
|
||||
preloadImages(data);
|
||||
|
||||
// apply filters from URL
|
||||
if (window.location.hash.length > 1) {
|
||||
const urlFilters = parseUrl(window.location.hash);
|
||||
|
||||
disinformantNationFilter.applyBoolArray(urlFilters.disinformantNations);
|
||||
platformFilter.applyBoolArray(urlFilters.platforms);
|
||||
methodFilter.applyBoolArray(urlFilters.methods);
|
||||
sourceFilter.applyBoolArray(urlFilters.sources);
|
||||
sourceCategoryFilter.applyBoolArray(urlFilters.sourceCategories);
|
||||
contextData.applyBoolArray(urlFilters.contextData);
|
||||
$attributionScoreFilter = urlFilters.attributionScores;
|
||||
$textSearchFilter = urlFilters.textSearch;
|
||||
$caseIdFilter = urlFilters.caseId;
|
||||
}
|
||||
});
|
||||
|
||||
// set the scales
|
||||
@ -152,6 +182,7 @@
|
||||
&& haveOverlap($sourceCategoryFilter, d.sourceCategory)
|
||||
&& includesTextSearch($textSearchFilter, d.search)
|
||||
&& withinRange($attributionScoreFilter, d.attributionScore)
|
||||
&& isCaseId($caseIdFilter, d.id)
|
||||
}));
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -18,6 +18,11 @@ function createInclusiveFilter() {
|
||||
const select = (id) => update((f) => f.map((d) => ({...d, selected: [id].flat().includes(d.id) ? true : d.selected})));
|
||||
const unselectAll = () => update((f) => f.map((d) => ({...d, selected: false})));
|
||||
|
||||
const applyBoolArray = (arr) => {
|
||||
const tmpArr = [...arr].reverse();
|
||||
update((f) => f.reverse().map((d, i) => ({...d, selected: tmpArr[i] !== undefined ? tmpArr[i] : false})).reverse());
|
||||
};
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set: (value) => set(value),
|
||||
@ -29,7 +34,8 @@ function createInclusiveFilter() {
|
||||
},
|
||||
selectAll: () => update((f) => f.map((d) => ({...d, selected: true}))),
|
||||
unselect: (id) => update((f) => f.map((d) => ({...d, selected: [id].flat().includes(d.id) ? false : d.selected}))),
|
||||
unselectAll
|
||||
unselectAll,
|
||||
applyBoolArray
|
||||
};
|
||||
}
|
||||
|
||||
@ -73,6 +79,7 @@ export const selectAllFilters = (disinformantNation = true) => {
|
||||
sourceCategoryFilter.selectAll();
|
||||
attributionScoreFilter.set(attributionScoreDef);
|
||||
textSearchFilter.reset();
|
||||
caseIdFilter.set(undefined);
|
||||
};
|
||||
|
||||
export const textSearchFilter = createTextSearchFilter();
|
||||
@ -81,3 +88,5 @@ export const contextData = createInclusiveFilter();
|
||||
|
||||
export const brushed = writable(false);
|
||||
export const originalTimeDomain = writable(null);
|
||||
|
||||
export const caseIdFilter = writable();
|
||||
|
||||
@ -44,6 +44,9 @@ export const withinRange = (arr, num) => num >= arr[0] && num <= arr[1];
|
||||
// check, if a search string (filter) is included in a string
|
||||
export const includesTextSearch = (filter, s) => s.indexOf(filter.toUpperCase()) > -1;
|
||||
|
||||
// check if case id filter is set and if id is matching
|
||||
export const isCaseId = (filter, id) => filter === undefined ? true : (filter === id);
|
||||
|
||||
// extract filter items from data
|
||||
export const extractFilterCategories = (data, name) =>
|
||||
uniq(data.map((d) => d[name]).flat());
|
||||
|
||||
48
src/utils/share.js
Обычный файл
48
src/utils/share.js
Обычный файл
@ -0,0 +1,48 @@
|
||||
export const baseUrl = 'https://interference2020.org';
|
||||
|
||||
export const urlFromFilters = (disinformantNations,
|
||||
platforms,
|
||||
methods,
|
||||
sources,
|
||||
sourceCategories,
|
||||
attributionScores,
|
||||
textSearch,
|
||||
contextData,
|
||||
caseId = '') => {
|
||||
const params = {
|
||||
ts: textSearch,
|
||||
as: [attributionScores[0], attributionScores[1]].join(';'),
|
||||
f: filtersToHex([disinformantNations, platforms, methods, sources, sourceCategories, contextData]),
|
||||
id: caseId
|
||||
};
|
||||
|
||||
return `${baseUrl}/#${params.f}-${params.id}-${params.ts}-${params.as}`;
|
||||
};
|
||||
|
||||
export const filtersToHex = (arr) => {
|
||||
const hex = arr.map((d) => binaryToHex(d.map((d) => +d.selected).join(''))).join('-');
|
||||
return hex;
|
||||
};
|
||||
|
||||
export const binaryToHex = (binary) => parseInt(binary , 2).toString(16).toLowerCase();
|
||||
|
||||
export const hexToBinary = (hex) => parseInt(hex, 16).toString(2);
|
||||
|
||||
export const binaryToBool = (binary) => binary.split('').map((d) => d === '0' ? false : true);
|
||||
|
||||
export const parseUrl = (hash) => {
|
||||
const s = hash.substring(1);
|
||||
const [ disinformantNations, platforms, methods, sources, sourceCategories, contextData, caseId, textSearch, attributionScores] = s.split('-');
|
||||
|
||||
return {
|
||||
disinformantNations: binaryToBool(hexToBinary(disinformantNations)),
|
||||
platforms: binaryToBool(hexToBinary(platforms)),
|
||||
methods: binaryToBool(hexToBinary(methods)),
|
||||
sources: binaryToBool(hexToBinary(sources)),
|
||||
sourceCategories: binaryToBool(hexToBinary(sourceCategories)),
|
||||
contextData: binaryToBool(hexToBinary(contextData)),
|
||||
caseId: caseId === '' ? undefined : +caseId,
|
||||
textSearch,
|
||||
attributionScores: attributionScores.split(';').map((d) => +d)
|
||||
};
|
||||
};
|
||||
Загрузка…
x
Ссылка в новой задаче
Block a user