Skip to content
Merged
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
227 changes: 227 additions & 0 deletions src/components/dither/bar-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import clsx from "clsx";
import { useEffect, useRef } from "react";
import {
CELL,
clamp01,
easeOutCubic,
paintColumn,
prefersReducedMotion,
} from "./dither-paint";
import { type DitherColor, seedOfColor } from "./palette";
import { useChartDimensions } from "./use-chart-dimensions";

export interface BarListItem {
/** Per-item hue override — e.g. `grey` for a bot or an "everything else" bucket. */
color?: DitherColor;
key: string;
label: React.ReactNode;
value: number;
/** Text shown at the end of the row; defaults to the localized value. */
valueLabel?: string;
}

// Fraction of the entrance spent staggering row starts.
const STAGGER = 0.55;
const ENTRANCE_MS = 900;
const TRACK_HEIGHT = 16; // css px — 8 dither cells tall
const DIM = 0.5;

interface BarPaintJob {
activeKey: string | null;
canvas: HTMLCanvasElement;
color: DitherColor;
item: BarListItem;
lengthCells: number;
max: number;
offscreen: HTMLCanvasElement;
offscreenContext: CanvasRenderingContext2D;
start: number;
thickCells: number;
}

/** Paint one bar at `progress` (0–1 of the entrance) onto its canvas. */
const paintBar = (progress: number, job: BarPaintJob): void => {
const context = job.canvas.getContext("2d");
if (!context) {
return;
}
// Setting the size also clears the canvas for this repaint.
job.canvas.width = job.lengthCells;
job.canvas.height = job.thickCells;
const grown =
(job.item.value / job.max) *
easeOutCubic(clamp01((progress - job.start) / (1 - STAGGER)));
if (grown <= 0) {
return;
}
const isActive = job.activeKey === job.item.key;
const dim = job.activeKey !== null && !isActive ? DIM : 1;
job.offscreenContext.clearRect(0, 0, job.thickCells, job.lengthCells);
const top = (1 - grown) * job.lengthCells;
const seed = seedOfColor(job.item.color ?? job.color);
for (let x = 0; x < job.thickCells; x += 1) {
paintColumn(job.offscreenContext, x, top, job.lengthCells, seed, {
dim,
intensity: isActive ? 1 : 0,
stacked: false,
variant: "gradient",
});
}
// Rotate the vertically-painted bar 90° clockwise: offscreen (x, y) lands at
// (lengthCells − y, x), so the baseline maps to the left edge.
context.save();
context.translate(job.lengthCells, 0);
context.rotate(Math.PI / 2);
context.drawImage(job.offscreen, 0, 0);
context.restore();
};

/**
* Horizontal dither **bar list** — one labelled row per item, values always
* visible, longest bar = the max. Each bar is painted with the shared
* {@link paintColumn} dither on an offscreen canvas (as a vertical bar) and
* blitted rotated 90°, so the baseline lands on the left and the soft outline
* on the value end. Rows grow in a staggered top-to-bottom wave.
*
* Emphasis is controlled: the row whose key is `activeKey` is lifted and the
* rest are dimmed, so several lists can highlight the same entity together.
* `renderRow` wraps each row's cells (e.g. in a button) to own the interaction.
*/
export function BarList({
activeKey = null,
className,
color = "blue",
items,
renderRow,
}: Readonly<{
activeKey?: string | null;
className?: string;
/** Series hue for items without their own `color`. */
color?: DitherColor;
items: BarListItem[];
renderRow?: (item: BarListItem, cells: React.ReactNode) => React.ReactNode;
}>) {
const { ref: trackRef, size } = useChartDimensions<HTMLDivElement>();
const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
const activeRef = useRef(activeKey);
const itemsRef = useRef(items);
const progressRef = useRef(0);
// Parents tend to rebuild `items` on every render; only a change in what the
// bars show should restart the entrance, so the effect keys on content.
const itemsKey = items
.map((item) => `${item.key}:${item.value}:${item.color ?? ""}`)
.join("|");

itemsRef.current = items;
const paintRef = useRef<(progress: number) => void>(() => {
// replaced once the paint effect runs
});

// biome-ignore lint/correctness/useExhaustiveDependencies: itemsKey stands in for items (see above)
useEffect(() => {
const { width } = size;
const currentItems = itemsRef.current;
if (width <= 0 || currentItems.length === 0) {
return;
}
const lengthCells = Math.max(8, Math.round(width / CELL));
const thickCells = Math.max(2, Math.round(TRACK_HEIGHT / CELL));
const max = Math.max(...currentItems.map((item) => item.value), 1);
const offscreen = document.createElement("canvas");
offscreen.width = thickCells;
offscreen.height = lengthCells;
const offscreenContext = offscreen.getContext("2d");
if (!offscreenContext) {
return;
}

const paintAll = (progress: number): void => {
currentItems.forEach((item, index) => {
const canvas = canvasRefs.current[index];
if (!canvas) {
return;
}
paintBar(progress, {
activeKey: activeRef.current,
canvas,
color,
item,
lengthCells,
max,
offscreen,
offscreenContext,
start:
currentItems.length > 1
? (index / (currentItems.length - 1)) * STAGGER
: 0,
thickCells,
});
});
};
paintRef.current = paintAll;

if (prefersReducedMotion()) {
progressRef.current = 1;
paintAll(1);
return;
}
let frame = 0;
let startTime = 0;
const tick = (now: number): void => {
if (!startTime) {
startTime = now;
}
const progress = Math.min(1, (now - startTime) / ENTRANCE_MS);
progressRef.current = progress;
paintAll(progress);
if (progress < 1) {
frame = requestAnimationFrame(tick);
}
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [size, itemsKey, color]);

// Emphasis changes are a repaint at the current progress, not a re-entrance.
useEffect(() => {
activeRef.current = activeKey;
paintRef.current(progressRef.current);
}, [activeKey]);

return (
<ul className={clsx("flex flex-col", className)}>
{items.map((item, index) => {
const cells = (
<>
<span className="min-w-0 text-base/6 text-zinc-600 sm:text-sm/6 dark:text-zinc-400">
{item.label}
</span>
<span
className="relative"
ref={index === 0 ? trackRef : undefined}
style={{ height: TRACK_HEIGHT }}
>
<canvas
aria-hidden
className="absolute inset-0 h-full w-full"
ref={(element) => {
canvasRefs.current[index] = element;
}}
style={{ imageRendering: "pixelated" }}
/>
</span>
<span className="text-right text-base/6 text-zinc-950 tabular-nums sm:text-sm/6 dark:text-white">
{item.valueLabel ?? item.value.toLocaleString()}
</span>
</>
);

return (
<li key={item.key}>
{renderRow === undefined ? cells : renderRow(item, cells)}
</li>
);
})}
</ul>
);
}
115 changes: 115 additions & 0 deletions src/components/dither/dither-paint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Ported from arsenstorm.com (MIT, © Arsen Shkrumelyak) — the ordered-dither
// paint engine behind its charts, trimmed to what the bar list needs.
import { rgb, type Seed } from "./palette";

const BAYER_SIZE = 4;
const BAYER_STEPS = 16;

// 4×4 ordered (Bayer) matrix, normalized to 0–1 thresholds.
export const BAYER = [
[0, 8, 2, 10],
[12, 4, 14, 6],
[3, 11, 1, 9],
[15, 7, 13, 5],
].map((row) => row.map((value) => (value + 0.5) / BAYER_STEPS));

export const CELL = 2; // css px per dither cell — chunky enough to read pixelated
// Opacity of the top border outline (just under solid, so it reads as a soft
// edge rather than a hard line).
export const BORDER_ALPHA = 0.72;
// Opacity of a dither "off" cell relative to an "on" cell. The scatter modulates
// between these two tiers of the *same* colour instead of leaving holes, so the
// background never shows through as stark white on a light theme.
export const OFF_TIER = 0.4;

const DOTTED_BIAS = 0.12;
const STACKED_BIAS = 0.2;
const HOVER_THRESHOLD_LIFT = 0.1;
const HOVER_ALPHA_LIFT = 0.22;
const MIN_DENSITY_ALPHA = 0.3;

export type PaintVariant = "dotted" | "gradient" | "hatched" | "solid";

export interface PaintOpts {
dim: number; // selection dim multiplier (0.5 dimmed, 1 normal)
intensity: number; // 0–1 hover lift
sparse?: number; // raise the dither threshold (thin out) — front layers
stacked: boolean; // denser + solid floor when layers stack
variant: PaintVariant;
}

const bayerAt = (x: number, y: number): number =>
BAYER[y % BAYER_SIZE]?.[x % BAYER_SIZE] ?? 0;

// Colour vs opacity — the guiding rule for the whole engine: every pixel is the
// series' single `fill` colour and only its alpha varies, so the same paint
// reads correctly on both light and dark backgrounds.

/**
* Fill one backing-canvas column `x` from row `top` down to `floor` with the
* ordered-dither scatter — solid at the floor, dissolving upward so it fades
* out toward the value line — then cap the top with a soft border outline in
* the series colour.
*/
export function paintColumn(
context: CanvasRenderingContext2D,
x: number,
top: number,
floor: number,
seed: Seed,
{ variant, intensity, dim, stacked, sparse = 0 }: PaintOpts
): void {
const t = Math.round(top);
const f = Math.round(floor);
const depth = f - t;
if (depth <= 0) {
context.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * dim);
context.fillRect(x, t, 1, 1);
return;
}
const bias =
(variant === "dotted" ? DOTTED_BIAS : 0) +
(stacked ? STACKED_BIAS : 0) -
sparse;
for (let y = t; y < f; y += 1) {
// Inverted falloff: 0 at the top line, 1 at the floor — dense at the
// bottom, thinning as it rises toward the outline.
let density = (y - t) / depth;
if (stacked) {
density = 0.5 + 0.5 * density;
}
if (variant === "hatched" && (x + y) % BAYER_SIZE >= 2) {
continue;
}
const lit =
variant === "solid" ||
density > bayerAt(x, y) - HOVER_THRESHOLD_LIFT * intensity - bias;
// "dotted" keeps real gaps for its open look; every other variant covers
// the cell and lets the dither ride the alpha.
if (variant === "dotted" && !lit) {
continue;
}
const k =
(MIN_DENSITY_ALPHA + density * (1 - MIN_DENSITY_ALPHA)) *
(1 + HOVER_ALPHA_LIFT * intensity);
const alpha = clamp01((lit ? k : k * OFF_TIER) * dim);
context.fillStyle = rgb(seed.fill, 1, alpha);
context.fillRect(x, y, 1, 1);
}
// Top border outline — kept just under full opacity, with a faint feather
// row beneath, so it reads as a soft edge rather than a hard line.
context.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * dim);
context.fillRect(x, t, 1, 1);
if (depth > 1) {
context.fillStyle = rgb(seed.fill, 1, BORDER_ALPHA * 0.5 * dim);
context.fillRect(x, t + 1, 1, 1);
}
}

export const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3;

export const clamp01 = (t: number): number => Math.min(1, Math.max(0, t));

/** Whether the OS asks for reduced motion (snap to the final frame). */
export const prefersReducedMotion = (): boolean =>
window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
44 changes: 44 additions & 0 deletions src/components/dither/palette.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export type Rgb = [number, number, number];

export type DitherColor =
| "green"
| "blue"
| "purple"
| "pink"
| "orange"
| "red"
| "grey";

export interface Seed {
fill: Rgb;
line: Rgb;
star: Rgb;
}

// Each seed: the area-fill hue, the bright series line, and the star sparkle.
export const PALETTE: Record<DitherColor, Seed> = {
blue: { fill: [53, 143, 243], line: [150, 200, 255], star: [205, 228, 255] },
green: { fill: [40, 210, 110], line: [150, 255, 180], star: [200, 255, 220] },
// No-data: a muted grey so empty metrics read as "nothing here".
grey: { fill: [92, 92, 100], line: [140, 140, 150], star: [165, 165, 175] },
orange: {
fill: [255, 150, 50],
line: [255, 195, 130],
star: [255, 220, 175],
},
pink: { fill: [240, 90, 190], line: [255, 170, 220], star: [255, 205, 235] },
purple: {
fill: [150, 110, 255],
line: [200, 175, 255],
star: [225, 210, 255],
},
red: { fill: [240, 70, 70], line: [255, 150, 140], star: [255, 195, 185] },
};

export const rgb = ([r, g, b]: Rgb, k = 1, a = 1) =>
`rgba(${Math.round(r * k)},${Math.round(g * k)},${Math.round(b * k)},${a})`;

export const seedOfColor = (color: DitherColor): Seed => PALETTE[color];

export const isDitherColor = (value: unknown): value is DitherColor =>
typeof value === "string" && value in PALETTE;
Loading