CHARTS IN THREE DIMENSIONS
Scales from Viz. Bars in 3D.
Foldkit Viz scales place every bar; effect-three eases them whenever the data changes. Hover a bar to read it, click to pin it, double-click to show only its series, or click empty space to clear.
The same maths, a new medium.
The band and linear scales, the nice domain and the gridline ticks come from Foldkit Viz, the D3-parity primitives behind our 2D charts. The legend and the data table carry each series' identity, so colour is never the only cue.
Explore the example source →See how it works.
The source running this example. Expand a file to follow the Model, Messages and view.
model.ts
import { Option, Schema } from 'effect';
/** The three series, back row to front row. */
export const Series = Schema.Literals(['search', 'social', 'direct']);
export type Series = typeof Series.Type;
export const seriesList: ReadonlyArray<Series> = Series.literals;
export const seriesLabel = {
search: 'Search',
social: 'Social',
direct: 'Direct',
} satisfies Record<Series, string>;
export const Month = Schema.Literals(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']);
export type Month = typeof Month.Type;
export const months: ReadonlyArray<Month> = Month.literals;
/** The page theme, so series colours match the validated step for its surface. */
export const Theme = Schema.Literals(['light', 'dark']);
export type Theme = typeof Theme.Type;
/** Months in calendar order, or by the total of the visible series, largest first. */
export const Order = Schema.Literals(['calendar', 'total']);
export type Order = typeof Order.Type;
export const Bar = Schema.Struct({ month: Month, series: Series });
export type Bar = typeof Bar.Type;
export const Model = Schema.Struct({
/** Seeds the sample data; "New data" moves to the next seed. */
seed: Schema.Int,
hidden: Schema.Array(Series),
order: Order,
hovered: Schema.Option(Bar),
/** Pinned by a click, cleared by a click on empty space. */
selected: Schema.Option(Bar),
theme: Theme,
});
export type Model = typeof Model.Type;
export const init = () => ({
model: {
seed: 1,
hidden: [],
order: 'calendar',
hovered: Option.none(),
selected: Option.none(),
theme: 'light',
} satisfies Model,
});
export const isShown = (model: Model, series: Series): boolean => !model.hidden.includes(series);
export const sameBar = (a: Bar, b: Bar): boolean => a.month === b.month && a.series === b.series;
layout.ts
import type { Vec3 } from '@opsydyn/effect-three';
/**
* Pure chart geometry: foldkit-viz scales (D3 parity) map months, series and values to
* world units. Bars are a unit box scaled to size, so a change in value is a change in
* position and scale, which the scene eases.
*/
import { band, linear, linearTicks, niceLinear } from '@opsydyn/foldkit-viz/math/scale';
import { totalOf, type Values } from './data';
import {
type Bar,
isShown,
type Model,
type Month,
months,
type Series,
seriesList,
} from './model';
/** World size of the plot: width (months), height (values) and depth (series). */
export const plot = { width: 7, height: 3.2, depth: 3 } as const;
/** Never scale a bar to zero: a degenerate matrix breaks lighting and hit testing. */
const flat = 0.001;
export type BarLayout = {
readonly key: string;
readonly bar: Bar;
readonly value: number;
readonly shown: boolean;
readonly position: Vec3;
readonly scale: Vec3;
};
export type Gridline = { readonly key: string; readonly value: number; readonly y: number };
export type ChartLayout = {
readonly months: ReadonlyArray<Month>;
readonly bars: ReadonlyArray<BarLayout>;
readonly gridlines: ReadonlyArray<Gridline>;
/** The value of one gridline step, for the caption. */
readonly step: number;
};
export const barKey = (bar: Bar): string => `bar:${bar.month}:${bar.series}`;
/** Reads a bar back from its scene key. */
export const barOf = (key: string): Bar | undefined => {
const [prefix, month, series] = key.split(':');
const knownMonth = months.find((candidate) => candidate === month);
const knownSeries = seriesList.find((candidate) => candidate === series);
if (prefix !== 'bar' || knownMonth === undefined || knownSeries === undefined) return undefined;
return { month: knownMonth, series: knownSeries };
};
export const orderedMonths = (model: Model, values: Values): ReadonlyArray<Month> => {
if (model.order === 'calendar') return months;
const shown = seriesList.filter((series) => isShown(model, series));
return [...months].sort((a, b) => totalOf(values, b, shown) - totalOf(values, a, shown));
};
export const layoutFor = (model: Model, values: Values): ChartLayout => {
const ordered = orderedMonths(model, values);
const x = band({
domain: ordered,
range: [-plot.width / 2, plot.width / 2],
paddingInner: 0.3,
paddingOuter: 0.15,
});
const z = band({
domain: seriesList,
range: [-plot.depth / 2, plot.depth / 2],
paddingInner: 0.55,
paddingOuter: 0.15,
});
const shownValues = months.flatMap((month) =>
seriesList.filter((series) => isShown(model, series)).map((series) => values[month][series]),
);
const domain = niceLinear([0, Math.max(1, ...shownValues)], 4);
const y = linear({ domain, range: [0, plot.height] });
const ticks = linearTicks(domain, 4);
const barLayout = (month: Month, series: Series): BarLayout => {
const value = values[month][series];
const shown = isShown(model, series);
const height = shown ? Math.max(flat, y(value)) : flat;
return {
key: barKey({ month, series }),
bar: { month, series },
value,
shown,
position: [
x.position(month) + x.bandwidth / 2,
height / 2,
z.position(series) + z.bandwidth / 2,
],
scale: [x.bandwidth, height, z.bandwidth],
};
};
return {
months: ordered,
bars: months.flatMap((month) => seriesList.map((series) => barLayout(month, series))),
gridlines: ticks
.filter((value) => value > 0)
.map((value) => ({ key: `grid:${value}`, value, y: y(value) })),
step: (ticks[1] ?? 0) - (ticks[0] ?? 0),
};
};
update.ts
import { Match, Option } from 'effect';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';
import { barOf } from './layout';
import { Message } from './message';
import {
type Bar,
isShown,
type Model,
type Order,
sameBar,
type Series,
seriesList,
} from './model';
type AppReturn = Return<Model, Message>;
const keepShown = (model: Model) => (bar: Option.Option<Bar>) =>
Option.filter(bar, (current) => isShown(model, current.series));
/** Applies a new hidden set, dropping a hover or pin on a series that is now hidden. */
const withHidden = (model: Model, hidden: ReadonlyArray<Series>): Model => {
const next = { ...model, hidden };
return modifyFields(next, { hovered: keepShown(next), selected: keepShown(next) });
};
const shownCount = (model: Model): number =>
seriesList.filter((series) => isShown(model, series)).length;
/** Hides or shows one series, but never hides the last one shown. */
const toggleSeries = (model: Model, series: Series): Model =>
Match.value({ hidden: !isShown(model, series), last: shownCount(model) === 1 }).pipe(
Match.when({ hidden: true }, () =>
withHidden(
model,
model.hidden.filter((hidden) => hidden !== series),
),
),
Match.when({ last: true }, () => model),
Match.orElse(() => withHidden(model, [...model.hidden, series])),
);
/** Shows only `series`, or everything again when it is already the only one shown. */
const isolate = (model: Model, series: Series): Model => {
const others = seriesList.filter((candidate) => candidate !== series);
const isolated = others.every((other) => model.hidden.includes(other));
// Already isolated: hide nothing. Otherwise hide every other series.
return withHidden(
model,
others.filter(() => !isolated),
);
};
/** Pins a bar, or unpins it when it is already pinned. */
const pin = (model: Model, bar: Bar): Model => {
const pinned = Option.filter(model.selected, (current) => sameBar(current, bar));
const selected = Option.match(pinned, {
onNone: () => Option.some(bar),
onSome: () => Option.none(),
});
return { ...model, selected };
};
const nextOrder = { calendar: 'total', total: 'calendar' } satisfies Record<Order, Order>;
const withBar = (key: string, model: Model, apply: (bar: Bar) => Model): AppReturn => {
const next = Option.match(Option.fromNullishOr(barOf(key)), {
onNone: () => model,
onSome: apply,
});
return { model: next };
};
export const update = (model: Model, message: Message): AppReturn =>
Message.match<AppReturn>(message, {
HoveredBar: ({ key }) =>
withBar(key, model, (bar) => modifyFields(model, { hovered: () => Option.some(bar) })),
LeftBar: ({ key }) =>
withBar(key, model, (bar) =>
modifyFields(model, {
hovered: (hovered) => Option.filter(hovered, (current) => !sameBar(current, bar)),
}),
),
ClickedBar: ({ key }) => withBar(key, model, (bar) => pin(model, bar)),
DoubleClickedBar: ({ key }) => withBar(key, model, (bar) => isolate(model, bar.series)),
ClickedEmptySpace: () => ({ model: modifyFields(model, { selected: () => Option.none() }) }),
ClickedNewData: () => ({ model: modifyFields(model, { seed: (seed) => seed + 1 }) }),
ClickedOrder: () => ({ model: modifyFields(model, { order: (order) => nextOrder[order] }) }),
ClickedSeries: ({ series }) => ({ model: toggleSeries(model, series) }),
ChangedTheme: ({ theme }) => ({ model: modifyFields(model, { theme: () => theme }) }),
ReceivedSceneLifecycle: () => ({ model }),
});
scene.ts
/**
* The chart as a scene: one shared unit box scaled into every bar, gridlines on the back
* wall and a floor plane. Every bar and gridline eases, so new data, sorting and hiding
* a series animate while the Model only ever holds the target.
*/
import {
ambientLight,
box,
directionalLight,
ease,
fixed,
mesh,
perspectiveCamera,
plane,
scene,
type SceneNode,
sharedGeometry,
sharedMaterial,
standard,
} from '@opsydyn/effect-three';
import { Option } from 'effect';
import { lighten, palettes } from './colours';
import { valuesFor } from './data';
import { type BarLayout, type Gridline, layoutFor, plot } from './layout';
import { type Model, sameBar, seriesList } from './model';
const glide = ease({ seconds: 0.6 });
const backWall = -plot.depth / 2 - 0.05;
const isLit = (model: Model, layout: BarLayout): boolean =>
[model.hovered, model.selected].some((bar) =>
Option.isSome(Option.filter(bar, (current) => sameBar(current, layout.bar))),
);
/** The series material, or its lighter step for the hovered or pinned bar. */
const materialOf = (model: Model, layout: BarLayout): string =>
Option.match(
Option.liftPredicate(layout, (bar) => isLit(model, bar)),
{
onNone: () => layout.bar.series,
onSome: () => `${layout.bar.series}:lit`,
},
);
const barNode = (model: Model, layout: BarLayout): SceneNode => {
const material = materialOf(model, layout);
return mesh({
key: layout.key,
position: layout.position,
scale: layout.scale,
transition: glide,
geometry: sharedGeometry('bar'),
material: sharedMaterial(material),
interactive: layout.shown,
});
};
const gridNode = (gridline: Gridline): SceneNode =>
mesh({
key: gridline.key,
position: [0, gridline.y, backWall],
scale: [plot.width, 0.015, 0.015],
transition: glide,
geometry: sharedGeometry('bar'),
material: sharedMaterial('grid'),
});
const materialsFor = (model: Model) => {
const palette = palettes[model.theme];
return [
...seriesList.flatMap((series) => [
{ id: series, material: standard({ color: palette[series], roughness: 0.55 }) },
{
id: `${series}:lit`,
material: standard({ color: lighten(palette[series], 0.35), roughness: 0.55 }),
},
]),
{ id: 'grid', material: standard({ color: palette.grid, roughness: 1 }) },
{ id: 'floor', material: standard({ color: palette.floor, roughness: 1 }) },
];
};
export const sceneFor = (model: Model) => {
const layout = layoutFor(model, valuesFor(model.seed));
return scene({
camera: perspectiveCamera({ fov: 34, position: [0.8, 6.4, 8.4], lookAt: [0, 0.9, -0.2] }),
resources: {
geometries: [{ id: 'bar', geometry: box() }],
materials: materialsFor(model),
},
children: [
ambientLight({ key: 'ambient', intensity: 0.75 }),
directionalLight({ key: 'key-light', intensity: 2, position: [3, 6, 5] }),
directionalLight({ key: 'fill-light', intensity: 0.5, position: [-4, 2, -3] }),
mesh({
key: 'floor',
// Just below the bars' bases, so flattened bars never fight the floor for depth.
position: [0, -0.005, 0],
rotation: fixed([-Math.PI / 2, 0, 0]),
geometry: plane({ width: plot.width + 0.4, height: plot.depth + 0.5 }),
material: sharedMaterial('floor'),
}),
...layout.gridlines.map(gridNode),
...layout.bars.map((bar) => barNode(model, bar)),
],
});
};
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Option } from 'effect';
import type { Document, Html, HtmlBuilder } from 'foldkit/html';
import { sceneMount } from '../environment';
import { seriesCss } from './colours';
import { type Values, valuesFor } from './data';
import { layoutFor, orderedMonths } from './layout';
import { Message } from './message';
import {
type Bar,
isShown,
type Model,
type Order,
type Series,
seriesLabel,
seriesList,
} from './model';
import { sceneFor } from './scene';
const orderLabel = {
calendar: 'Sort by total',
total: 'Calendar order',
} satisfies Record<Order, string>;
const button = (h: HtmlBuilder<Message>, label: string, message: Message): Html =>
h.button([h.Class('e3-button'), h.Type('button'), h.OnClick(message)], [label]);
/** A legend entry that also toggles its series: identity never rests on colour alone. */
const legendEntry = (model: Model, h: HtmlBuilder<Message>, series: Series): Html =>
h.button(
[
h.Class('e3-button'),
h.Type('button'),
h.AriaPressed(String(isShown(model, series))),
h.OnClick(Message.ClickedSeries({ series })),
],
[
h.span([h.Class('e3-swatch'), h.Style({ background: seriesCss(model.theme, series) })], []),
seriesLabel[series],
],
);
const describe = (values: Values, bar: Bar): string =>
`${bar.month} · ${seriesLabel[bar.series]}: ${values[bar.month][bar.series]} visits`;
/** The hovered bar, else the pinned one, else a hint. */
const readout = (model: Model, values: Values): string =>
Option.match(
Option.orElse(model.hovered, () => model.selected),
{
onNone: () => 'Hover a bar to read it. Click to pin it; click empty space to clear.',
onSome: (bar) => describe(values, bar),
},
);
const dataTable = (model: Model, values: Values, h: HtmlBuilder<Message>): Html => {
const shown = seriesList.filter((series) => isShown(model, series));
return h.details(
[h.Class('e3-table')],
[
h.summary([], ['Data table']),
h.table(
[],
[
h.caption([], ['Monthly visits by channel (sample data)']),
h.thead(
[],
[
h.tr(
[],
[
h.th([h.Scope('col')], ['Month']),
...shown.map((series) => h.th([h.Scope('col')], [seriesLabel[series]])),
],
),
],
),
h.tbody(
[],
orderedMonths(model, values).map((month) =>
h.tr(
[],
[
h.th([h.Scope('row')], [month]),
...shown.map((series) => h.td([], [String(values[month][series])])),
],
),
),
),
],
),
],
);
};
export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
const values = valuesFor(model.seed);
const { step } = layoutFor(model, values);
return {
title: '3D bar chart — effect-three',
body: h.div(
[h.Class('e3-sample')],
[
EffectThreeFoldkit.view(
{
scene: sceneFor(model),
mount: sceneMount,
className: 'e3-stage',
onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
onPointerEnter: ({ key }) => Message.HoveredBar({ key }),
onPointerLeave: ({ key }) => Message.LeftBar({ key }),
onClick: ({ key }) => Message.ClickedBar({ key }),
onDoubleClick: ({ key }) => Message.DoubleClickedBar({ key }),
onPointerMissed: () => Message.ClickedEmptySpace(),
},
h,
),
h.p([h.Class('e3-readout'), h.AriaLive('polite')], [readout(model, values)]),
h.div(
[h.Class('e3-controls')],
[
button(h, 'New data', Message.ClickedNewData()),
button(h, orderLabel[model.order], Message.ClickedOrder()),
],
),
h.div(
[h.Class('e3-controls'), h.AriaLabel('Series')],
seriesList.map((series) => legendEntry(model, h, series)),
),
h.p(
[h.Class('e3-note')],
[
`Gridlines every ${step} visits. Toggle a series in the legend, or double-click a bar to show only its series. `,
'Every change eases: the Model holds the target, the scene shows the journey.',
],
),
dataTable(model, values, h),
],
),
};
};
subscription.ts
/**
* The page theme as a subscription: the host page owns it (a `data-theme` attribute on
* the root element, dark when absent or unknown), and this app only follows it. Emits
* the current theme first, then every change.
*/
import { type Cause, Effect, Option, Queue, Schema, Stream } from 'effect';
import { Subscription } from 'foldkit';
import { Message } from './message';
import { type Model, Theme } from './model';
const themeOf = (root: HTMLElement): Theme =>
Option.getOrElse(Option.liftPredicate(root.dataset.theme, Schema.is(Theme)), () => 'dark');
const observeTheme = (queue: Queue.Queue<Theme, Cause.Done>) => (): MutationObserver => {
const root = document.documentElement;
const observer = new MutationObserver(() => Queue.offerUnsafe(queue, themeOf(root)));
Queue.offerUnsafe(queue, themeOf(root));
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
return observer;
};
const disconnect = (observer: MutationObserver) => () => observer.disconnect();
const watchTheme = (queue: Queue.Queue<Theme, Cause.Done>) => {
const observe = Effect.sync(observeTheme(queue));
return Effect.acquireRelease(observe, (observer) => Effect.sync(disconnect(observer)));
};
// The observer lives in the stream's scope: it disconnects when the subscription ends.
const themes = Stream.callback<Theme>(watchTheme);
export const subscriptions = Subscription.make<Model, Message>()(() => ({
theme: Subscription.persistentEntry(
themes.pipe(
Stream.changes,
Stream.map((theme) => Message.ChangedTheme({ theme })),
),
),
}));