← All examples

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 })),
    ),
  ),
}));