Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bar Chart Race example #123

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fluffy-suns-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"layerchart": patch
---

[Labels] Support passing slot. Key based on label text to enable tweening`
76 changes: 57 additions & 19 deletions packages/layerchart/src/lib/components/Labels.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
},
});

$: getLabelText = (item) => (isScaleBand($yScale) ? $y(item) : $x(item));
$: getValue = (item) => (isScaleBand($yScale) ? $x(item) : $y(item));

$: getLabelValue = (item) => {
Expand All @@ -58,7 +59,7 @@
return formattedValue ?? '';
};

$: getTextProps = (item: any): ComponentProps<Text> => {
$: getPosition = (item: any): { x: number; y: number } => {
const labelValue = getLabelValue(item);
const dimensions = $getDimensions(item);

Expand All @@ -69,15 +70,48 @@
return {
x: dimensions?.x + (placement === 'outside' ? -offset : offset),
y: dimensions?.y + (dimensions?.height ?? 0) / 2,
};
} else {
// right
return {
x: dimensions?.x + dimensions?.width + (placement === 'outside' ? offset : -offset),
y: dimensions?.y + (dimensions?.height ?? 0) / 2,
};
}
} else {
// Position label top/bottom on vertical bars
if (labelValue < 0) {
// bottom
return {
x: dimensions?.x + (dimensions?.width ?? 0) / 2,
y: dimensions?.y + dimensions?.height + (placement === 'outside' ? offset : -offset),
};
} else {
// top
return {
x: dimensions?.x + (dimensions?.width ?? 0) / 2,
y: dimensions?.y + (placement === 'outside' ? -offset : offset),
};
}
}
};

$: getTextProps = (item: any): ComponentProps<Text> => {
const labelValue = getLabelValue(item);
const dimensions = $getDimensions(item);

if (isScaleBand($yScale)) {
// Position label left/right on horizontal bars
if (labelValue < 0) {
// left
return {
textAnchor: placement === 'outside' ? 'end' : 'start',
verticalAnchor: 'middle',
capHeight: '.6rem',
};
} else {
// right
return {
x: dimensions?.x + dimensions?.width + (placement === 'outside' ? offset : -offset),
y: dimensions?.y + (dimensions?.height ?? 0) / 2,
textAnchor: placement === 'outside' ? 'start' : 'end',
verticalAnchor: 'middle',
capHeight: '.6rem',
Expand All @@ -88,17 +122,13 @@
if (labelValue < 0) {
// bottom
return {
x: dimensions?.x + (dimensions?.width ?? 0) / 2,
y: dimensions?.y + dimensions?.height + (placement === 'outside' ? offset : -offset),
capHeight: '.6rem',
textAnchor: 'middle',
verticalAnchor: placement === 'outside' ? 'start' : 'end',
};
} else {
// top
return {
x: dimensions?.x + (dimensions?.width ?? 0) / 2,
y: dimensions?.y + (placement === 'outside' ? -offset : offset),
capHeight: '.6rem',
textAnchor: 'middle',
verticalAnchor: placement === 'outside' ? 'end' : 'start',
Expand All @@ -109,18 +139,26 @@
</script>

<g class="Labels">
{#each $flatData as item, index}
<!-- TODO: Determine way to set lookup -->
<!-- {#each $flatData as d, i (getLabelText(d))} -->
{#each $flatData as d}
{@const value = getValue(d)}
{@const position = getPosition(d)}
{@const textProps = getTextProps(d)}
<!-- TODO: Add labels for each item when array/stack? Use `getValue(item)` instead and iterate -->
<Text
value={getFormattedValue(item)}
class={cls(
'text-xs',
placement === 'inside'
? 'fill-surface-300 stroke-surface-content'
: 'fill-surface-content stroke-surface-100'
)}
{...getTextProps(item)}
{...$$restProps}
/>
<slot data={d} {value} {position} {textProps}>
<Text
value={getFormattedValue(d)}
class={cls(
'text-xs',
placement === 'inside'
? 'fill-surface-300 stroke-surface-content'
: 'fill-surface-content stroke-surface-100'
)}
{...position}
{...textProps}
{...$$restProps}
/>
</slot>
{/each}
</g>
1 change: 1 addition & 0 deletions packages/layerchart/src/routes/_NavMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
'AreaStack',
{ label: 'Bar Chart (Vertical)', value: 'Columns' },
{ label: 'Bar Chart (Horizontal)', value: 'Bars' },
{ label: 'Bar Chart Race', value: 'BarChartRace' },
'Candlestick',
'DotPlot',
'Histogram',
Expand Down
153 changes: 153 additions & 0 deletions packages/layerchart/src/routes/docs/examples/BarChartRace/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<script lang="ts">
import { scaleBand, scaleOrdinal } from 'd3-scale';
import { rollup } from 'd3-array';
import { quantize } from 'd3-interpolate';
import {
interpolateInferno,
interpolateRainbow,
interpolateRdBu,
interpolateSpectral,
interpolateViridis,
schemeSpectral,
schemeTableau10,
} from 'd3-scale-chromatic';

import {
PeriodType,
NumberStepper,
sort,
format,
timerStore,
ButtonGroup,
Button,
Field,
Switch,
} from 'svelte-ux';

import Chart, { Svg } from '$lib/components/Chart.svelte';
import Axis from '$lib/components/Axis.svelte';
import Bar from '$lib/components/Bar.svelte';
import Group from '$lib/components/Group.svelte';
import Highlight from '$lib/components/Highlight.svelte';
import Preview from '$lib/docs/Preview.svelte';
import Text from '$lib/components/Text.svelte';
import Tooltip from '$lib/components/Tooltip.svelte';
import TooltipItem from '$lib/components/TooltipItem.svelte';

import Labels from '$lib/components/Labels.svelte';
import Rule from '$lib/components/Rule.svelte';
import ChartClipPath from '$lib/components/ChartClipPath.svelte';

export let data;

const duration = 250;
let xNice = false;

const frameTimer = timerStore({
initial: 0,
onTick: (value) => {
if (value == null || value >= data.keyframes.length - 1) {
frameTimer.stop();
return value;
} else {
return value + 1;
}
},
delay: duration,
disabled: true,
});
$: ({ isRunning } = frameTimer);

$: keyframe = data.keyframes[$frameTimer];
$: chartData = sort(keyframe?.data ?? [], (d) => d.value, 'desc');

const categoryByName = rollup(
data.data,
(values) => values[0].category,
(d) => d.name
);
// const colors = schemeTableau10;
const colors = schemeSpectral[10];
// const colors = quantize(interpolateSpectral, 10);
const colorScale = scaleOrdinal()
.domain(Array.from(categoryByName.values()).sort())
.range(colors);

$: console.log({ data, keyframe, chartData });
</script>

<h1>Examples</h1>

<div class="grid grid-cols-[1fr,auto,auto] items-center mb-2">
<div class="flex items-center gap-3">
<ButtonGroup variant="fill-light" class="ml-3">
<Button on:click={frameTimer.start} disabled={$isRunning}>Start</Button>
<Button on:click={frameTimer.stop} disabled={!$isRunning}>Stop</Button>
</ButtonGroup>
<Button on:click={frameTimer.reset}>Reset</Button>

<NumberStepper
value={$frameTimer}
on:change={(e) => {
$frameTimer = e.detail.value;
}}
/>

<Field let:id>
<label class="flex gap-2 items-center text-sm">
Nice
<Switch bind:checked={xNice} {id} />
</label>
</Field>
</div>

<div class="text-xl">{format(keyframe?.date, PeriodType.MonthYear)}</div>
</div>

<Preview data={chartData}>
<div class="h-[500px] p-4 border rounded">
<Chart
data={chartData}
x="value"
xDomain={[0, null]}
{xNice}
y="name"
yScale={scaleBand().padding(0.1)}
yDomain={chartData.map((d) => d.name)}
padding={{ top: 14, left: 4, right: 24 }}
tooltip={{ mode: 'band' }}
>
<Svg>
<ChartClipPath _height={272}>
<g>
{#each chartData as d (d.name)}
<Bar
bar={d}
radius={2}
fill={colorScale(categoryByName.get(d.name))}
fill-opacity={0.9}
class="stroke-1 stroke-surface-content/50"
tweened={{ duration }}
/>
{/each}
</g>
<!-- <Axis placement="left" rule tweened={{ duration }} /> -->
<Rule x />
<Axis placement="top" grid rule tweened={{ duration }} />
<Highlight area />
<!-- <Labels tweened format="integer" placement="inside" /> -->
<Labels tweened format="integer" placement="inside" let:data let:position let:textProps>
<Group {...position} tweened={{ duration }}>
<Text value={data.name} class="fill-black/50 mix-blend-multiply" {...textProps} />
</Group>
</Labels>
</ChartClipPath>
</Svg>

<Tooltip header={(d) => d.name} let:data>
<TooltipItem label="value" value={data.value} format="integer" />
<TooltipItem label="category" value={categoryByName.get(data.name)} />
</Tooltip>
</Chart>
</div>
</Preview>
92 changes: 92 additions & 0 deletions packages/layerchart/src/routes/docs/examples/BarChartRace/+page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { csvParse, autoType } from 'd3-dsv';
import { group, pairs, rollup } from 'd3-array';

import { sortFunc } from 'svelte-ux';

import pageSource from './+page.svelte?raw';

let numBars = 12;

function chartData(names: Set<string>, valueFunction: Function) {
const data = Array.from(names, (name) => {
return { name, value: valueFunction(name) };
}).sort(sortFunc((d) => d.value, 'desc'));

let chartNames = [];
for (let i = 0; i < data.length; ++i) {
data[i].rank = i;
if (i < numBars) chartNames.push(data[i].name);
}
return { names: chartNames, data: data };
}

export async function load() {
let data = await fetch('/data/examples/category-brands.csv').then(async (r) => {
return csvParse(await r.text(), autoType);
});

const dataByDateAndName = Array.from(
rollup(
data,
([d]) => d.value,
(d) => d.date,
(d) => d.name
)
)
.map(([date, data]) => [date, data])
.sort(sortFunc((d) => d[0]));

// all brand names in the dataset
let names = new Set(data.map((d) => d.name));

// create keyframes that interpolate between each date (year) in the dataset
let keyframes = [];
let dateLeft: Date;
let dataByNameLeft: Map<string, number>;
let dateRight: Date;
let dataByNameRight: Map<string, number>;
let k = 10;
let allChartNames = [];

for ([[dateLeft, dataByNameLeft], [dateRight, dataByNameRight]] of pairs(dataByDateAndName)) {
for (let i = 0; i < k; ++i) {
const t = i / k;
let tmp = chartData(
names,
(name) => (dataByNameLeft.get(name) || 0) * (1 - t) + (dataByNameRight.get(name) || 0) * t
);
allChartNames = allChartNames.concat(tmp.names);
keyframes.push({
date: new Date(dateLeft * (1 - t) + dateRight * t),
data: tmp.data,
});
}
}

let tmp = chartData(names, (name) => dataByNameRight.get(name) || 0);
allChartNames = allChartNames.concat(tmp.names);
keyframes.push({ date: new Date(dateRight), data: tmp.data });
let namesInChart = Array.from(new Set(allChartNames));

let finalKeyframes = [];
for (let i = 0; i < keyframes.length; ++i) {
let newKeyframe = { date: keyframes[i].date, data: [] };
let keyframeMap = new Map(keyframes[i].data.map((d) => [d.name, d]));
for (let j = 0; j < namesInChart.length; ++j) {
newKeyframe.data.push(keyframeMap.get(namesInChart[j]));
}
finalKeyframes.push(newKeyframe);
}

keyframes = finalKeyframes;

return {
data,
namesInChart,
keyframes,
meta: {
pageSource,
hideTableOfContents: true,
},
};
}
Loading
Loading