← All examples

LABELS

Every word is data.

A dark gallery whose wall curves round you, hung with distance-field labels drawn by troika. The title follows the wall's curve, the code plaque is coloured by colour ranges, and with the lights on every label takes a lit material, so the spotlights show on the type. Each plaque's panel is sized from its label's measured layout, which comes back as a Message: write on the wall and watch the panel grow as your note wraps. Click a plaque to walk up to it; drag to look around.

Text you can describe.

Colour runs, curves and materials are fields on a label, so the Model decides them like anything else. troika lays the text out in a worker; the scene reports each layout, and the Model sizes what goes behind it.

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 { perspectiveCamera, SceneSchema, type Vec3 } from '@opsydyn/effect-three';
import { Schema } from 'effect';

/** A label's laid-out block, `[minX, minY, maxX, maxY]`, as `LabelMeasured` reports it. */
export const Block = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number]);
export type Block = typeof Block.Type;

export const Model = Schema.Struct({
  assetBase: Schema.String,
  /** Whether the labels take the scene's lights (a lit material) or troika's own, unlit. */
  lit: Schema.Boolean,
  /** The plaque the camera has walked up to, if any. */
  focus: Schema.NullOr(Schema.String),
  /** What the visitor wrote on the wall. */
  note: Schema.String,
  /** Each measured label's laid-out block: the panels behind them are sized from these. */
  layouts: Schema.Record(Schema.String, Block),
  camera: SceneSchema.Camera,
});
export type Model = typeof Model.Type;

/** The gallery wall: an arc of this radius round the visitor, centred here. */
const centre: Vec3 = [0, 0, 4];
export const wall = { radius: 6, centre } as const;

/** Where on the wall an angle (degrees; 0 straight ahead, positive to the right) is. */
export const onWall = (degrees: number, y: number, inset = 0): Vec3 => {
  const angle = (degrees * Math.PI) / 180;
  const radius = wall.radius - inset;
  return [wall.centre[0] + Math.sin(angle) * radius, y, wall.centre[2] - Math.cos(angle) * radius];
};

/** The turn about y that faces something at `degrees` back towards the visitor. */
export const facing = (degrees: number): Vec3 => [0, (-degrees * Math.PI) / 180, 0];

export type Plaque = {
  readonly id: string;
  readonly degrees: number;
  readonly text: string;
  readonly fontSize: number;
  readonly maxWidth: number;
};

export const noteLimit = 90;

export const homeCamera = perspectiveCamera({
  fov: 58,
  position: [0, 1.8, 5.6],
  lookAt: [0, 2.2, -2],
  damping: 0.06,
});

export const initialModel = (assetBase: string): Model => ({
  assetBase,
  lit: true,
  focus: null,
  note: 'Type here: the panel grows to fit.',
  layouts: {},
  camera: homeCamera,
});
message.ts
import { SceneSchema } from '@opsydyn/effect-three';
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Schema } from 'effect';
import { defineMessageUnion } from 'foldkit/message';

export const Message = defineMessageUnion({
  ClickedPlaque: { id: Schema.String },
  ClickedAway: {},
  ToggledLights: {},
  ChangedNote: { note: Schema.String },
  /** A measured label was laid out: its block, for the panel behind it. */
  MeasuredLabel: { measured: SceneSchema.LabelMeasuredDetail },
  MadeGesture: { gesture: SceneSchema.Gesture },
  ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import { type Gesture, orbit, orbitOptions, perspectiveCamera } from '@opsydyn/effect-three';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';

import { Message } from './message';
import { homeCamera, initialModel, type Model, noteLimit, onWall } from './model';
import { plaques } from './plaques';

type AppReturn = Return<Model, Message>;

/** The camera walked up to a plaque, three metres back from it, looking at it. */
const cameraAt = (degrees: number) =>
  perspectiveCamera({
    ...homeCamera,
    position: onWall(degrees, 1.8, 3.1),
    lookAt: onWall(degrees, 1.9, 0),
  });

/** Clicking the plaque in focus steps back; clicking another walks up to it. */
const focused = (model: Model, id: string): Model => {
  const plaque = plaques.find((candidate) => candidate.id === id);
  if (plaque === undefined || model.focus === id) {
    return { ...model, focus: null, camera: homeCamera };
  }
  return { ...model, focus: id, camera: cameraAt(plaque.degrees) };
};

const orbitLimits = orbitOptions({
  minDistance: 1.5,
  maxDistance: 7,
  minAzimuthAngle: -0.9,
  maxAzimuthAngle: 0.9,
  minPolarAngle: 1.1,
  maxPolarAngle: 1.75,
});

/** Look around, unless the gesture began on a plaque (a click on it walks up to it). */
const orbited = (model: Model, gesture: Gesture): Model =>
  'key' in gesture && gesture.key !== undefined
    ? model
    : { ...model, camera: orbit(model.camera, gesture, orbitLimits) };

export const init = (props: { readonly assetBase?: string } = {}) => ({
  model: initialModel(props.assetBase ?? '/models/'),
});

export const update = (model: Model, message: Message): AppReturn =>
  Message.match<AppReturn>(message, {
    ClickedPlaque: ({ id }) => ({ model: focused(model, id) }),
    ClickedAway: () => ({ model: { ...model, focus: null, camera: homeCamera } }),
    ToggledLights: () => ({ model: modifyFields(model, { lit: (lit) => !lit }) }),
    ChangedNote: ({ note }) => ({ model: { ...model, note: note.slice(0, noteLimit) } }),
    MeasuredLabel: ({ measured }) => ({
      model: { ...model, layouts: { ...model.layouts, [measured.key]: measured.block } },
    }),
    MadeGesture: ({ gesture }) => ({ model: orbited(model, gesture) }),
    ReceivedSceneLifecycle: () => ({ model }),
  });
scene.ts
/**
 * The type gallery: a dark room whose wall is an arc round the visitor. Everything on it
 * is a label described as data:
 *
 * - the title follows the wall's curve (`curveRadius`, the wall's radius);
 * - the code plaque is coloured by `colorRanges` from a small highlighter;
 * - with the lights on, the labels take a lit `material`, so the spotlights show on them;
 * - each plaque's panel is sized from its label's measured layout (`LabelMeasured`), so
 *   the note the visitor types grows its panel as it wraps.
 */
import {
  ambientLight,
  colorRange,
  environment,
  fixed,
  group,
  label,
  type LabelNode,
  mesh,
  physical,
  plane,
  renderSettings,
  scene,
  type SceneNode,
  spotLight,
  standard,
} from '@opsydyn/effect-three';

import { paintColour } from '../palette';
import { highlight } from './highlight';
import { type Block, facing, type Model, onWall, type Plaque, wall } from './model';
import { code, noteDegrees, plaques } from './plaques';

const textKey = (id: string): string => `${id}:text`;

/** The wall: an arc of dark panels, sixteen degrees each, from −64° to 64°. */
const wallPanels: ReadonlyArray<SceneNode> = Array.from({ length: 9 }, (_, index) => {
  const degrees = -64 + index * 16;
  return mesh({
    key: `wall:${index}`,
    position: onWall(degrees, 3.5),
    rotation: fixed(facing(degrees)),
    geometry: plane({ width: 2 * wall.radius * Math.sin((8 * Math.PI) / 180) + 0.02, height: 7 }),
    material: standard({ color: 0x1c1f27, roughness: 0.95 }),
    receiveShadow: true,
  });
});

const floor: SceneNode = mesh({
  key: 'floor',
  rotation: fixed([-Math.PI / 2, 0, 0]),
  position: [0, 0, 1],
  geometry: plane({ width: 30, height: 30 }),
  material: standard({ color: 0x121419, roughness: 0.6, metalness: 0.2 }),
  receiveShadow: true,
});

const font = (model: Model): string => `${model.assetBase}inter-latin-400-normal.woff`;

/** A lit material for the labels when the lights are on; troika's own, unlit, when off. */
const ink = (model: Model) => (model.lit ? standard({ roughness: 0.55, metalness: 0.05 }) : null);

const title = (model: Model): ReadonlyArray<SceneNode> => [
  label({
    key: 'title',
    text: 'effect-three',
    font: font(model),
    fontSize: 0.95,
    letterSpacing: -0.02,
    position: onWall(0, 3.95, 0.06),
    // Concave, round the visitor: the wall's own curve.
    curveRadius: wall.radius - 0.06,
    color: 0xf2efe8,
    colorRanges: [colorRange(0, 0xf2efe8), colorRange(7, paintColour.gold)],
    material: model.lit ? physical({ roughness: 0.35, metalness: 0.2, clearcoat: 0.6 }) : null,
  }),
  label({
    key: 'subtitle',
    text: 'A GALLERY OF LABELS, EVERY ONE OF THEM DATA',
    font: font(model),
    fontSize: 0.16,
    letterSpacing: 0.18,
    position: onWall(0, 3.25, 0.06),
    curveRadius: wall.radius - 0.06,
    color: 0x9aa0ab,
    material: ink(model),
  }),
];

/** The panel behind a label, sized to its laid-out block with a margin, once measured. */
const panel = (
  id: string,
  block: Block | undefined,
  selected: boolean,
): ReadonlyArray<SceneNode> => {
  if (block === undefined) return [];
  const [minX, minY, maxX, maxY] = block;
  return [
    mesh({
      key: `${id}:panel`,
      position: [(minX + maxX) / 2, (minY + maxY) / 2, -0.015],
      geometry: plane({ width: maxX - minX + 0.36, height: maxY - minY + 0.3 }),
      material: standard({
        color: selected ? 0x2c3346 : 0x262a33,
        roughness: 0.7,
        emissive: paintColour.blue,
        emissiveIntensity: selected ? 0.08 : 0,
      }),
      receiveShadow: true,
    }),
  ];
};

const plaqueText = (model: Model, plaque: Plaque): LabelNode =>
  label({
    key: textKey(plaque.id),
    text: plaque.text,
    font: font(model),
    fontSize: plaque.fontSize,
    maxWidth: plaque.maxWidth,
    lineHeight: 1.35,
    textAlign: 'left',
    color: 0xeae6de,
    colorRanges:
      plaque.id === 'code'
        ? highlight(code)
        : [colorRange(0, paintColour.gold)].concat(
            // The first line (the headline) in gold, the rest in the plaque's ink.
            plaque.text.includes('\n') ? [colorRange(plaque.text.indexOf('\n'), 0xeae6de)] : [],
          ),
    material: ink(model),
  });

const hung = (model: Model, plaque: Plaque): SceneNode =>
  group({
    key: plaque.id,
    // Far enough off the wall that a flat plaque clears its curve: a 3.4-unit plaque on a
    // 6-unit radius sags a quarter of a unit at its edges.
    position: onWall(plaque.degrees, 1.9, 0.35),
    rotation: fixed(facing(plaque.degrees)),
    interactive: true,
    children: [
      ...panel(plaque.id, model.layouts[textKey(plaque.id)], model.focus === plaque.id),
      plaqueText(model, plaque),
    ],
  });

const note = (model: Model): Plaque => ({
  id: 'note',
  degrees: noteDegrees,
  text: model.note.length === 0 ? '…' : model.note,
  fontSize: 0.2,
  maxWidth: 2.2,
});

/** A spotlight from the ceiling onto a plaque. */
const spot = (key: string, degrees: number, intensity: number, height = 1.9, angle = 0.42) =>
  spotLight({
    key,
    position: onWall(degrees, 4.8, 2.4),
    target: onWall(degrees, height, 0),
    intensity,
    angle,
    penumbra: 0.75,
    decay: 2,
    color: 0xfff1dc,
  });

export const measured: ReadonlyArray<string> = [...plaques.map((plaque) => plaque.id), 'note'].map(
  textKey,
);

export const sceneFor = (model: Model) => {
  const hangings = [...plaques, note(model)];
  return scene({
    camera: model.camera,
    gestures: 'scene',
    measure: measured,
    environment: environment({ environmentIntensity: 0.15 }),
    // No tone mapping: ACES pushes the type's bright, saturated colours towards white.
    render: renderSettings({ toneMapping: 'none' }),
    children: [
      ambientLight({ key: 'ambient', intensity: model.lit ? 0.1 : 0.5 }),
      ...hangings.map((plaque) => spot(`spot:${plaque.id}`, plaque.degrees, model.lit ? 26 : 0)),
      spot('spot:title', 0, model.lit ? 34 : 0, 3.7, 0.95),
      floor,
      ...wallPanels,
      ...title(model),
      ...hangings.map((plaque) => hung(model, plaque)),
    ],
  });
};
highlight.ts
/**
 * A small highlighter for the code plaque: colour ranges over a TypeScript snippet, by
 * character index, as `label({ colorRanges })` takes them. Function calls, property
 * names, strings and punctuation each get a colour; everything else is the label's own.
 */
import { colorRange, type LabelColorRange, type Rgb } from '@opsydyn/effect-three';

export const codeColours = {
  plain: 0xe6e3dc,
  call: 0x5b9bff,
  property: 0xb98cff,
  string: 0x4fe0ad,
  punctuation: 0x7d838e,
} as const;

const token =
  /(?<call>[A-Za-z_]\w*)(?=\()|(?<property>[A-Za-z_]\w*)(?=:)|(?<string>'[^']*')|(?<punctuation>[{}()[\],.:=>])/g;

const kinds = ['call', 'property', 'string', 'punctuation'] as const;

/** Colour ranges for `code`: each token in its colour, the text after it back to plain. */
export const highlight = (code: string): ReadonlyArray<LabelColorRange> => {
  const ranges: Array<LabelColorRange> = [colorRange(0, codeColours.plain)];
  for (const match of code.matchAll(token)) {
    const kind = kinds.find((name) => match.groups?.[name] !== undefined);
    if (kind === undefined || match.index === undefined) continue;
    const colour: Rgb = codeColours[kind];
    ranges.push(colorRange(match.index, colour));
    ranges.push(colorRange(match.index + match[0].length, codeColours.plain));
  }
  return ranges;
};
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import type { Document, Html, HtmlBuilder } from 'foldkit/html';

import { sceneMount } from '../labelledEnvironment';
import { Message } from './message';
import { type Model, noteLimit } from './model';
import { plaques } from './plaques';
import { sceneFor } from './scene';

const button = (h: HtmlBuilder<Message>, label: string, message: Message): Html =>
  h.button([h.Class('e3-button'), h.Type('button'), h.OnClick(message)], [label]);

/** The plaque a hit names: its group is the plaque, whichever part was hit. */
const plaqueOf = (key: string): string => key.split(':')[0] ?? key;

const isPlaque = (id: string): boolean =>
  id === 'note' || plaques.some((plaque) => plaque.id === id);

const width = (model: Model, key: string): string => {
  const block = model.layouts[key];
  return block === undefined
    ? '…'
    : `${(block[2] - block[0]).toFixed(2)} × ${(block[3] - block[1]).toFixed(2)}`;
};

export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  title: 'Type gallery — effect-three',
  body: h.div(
    [h.Class('e3-sample')],
    [
      EffectThreeFoldkit.view(
        {
          scene: sceneFor(model),
          mount: sceneMount,
          className: 'e3-stage e3-stage-tall',
          onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
          onClick: ({ key }) =>
            isPlaque(plaqueOf(key))
              ? Message.ClickedPlaque({ id: plaqueOf(key) })
              : Message.ClickedAway(),
          onPointerMissed: () => Message.ClickedAway(),
          onLabelMeasured: (measured) => Message.MeasuredLabel({ measured }),
          onGesture: (gesture) => Message.MadeGesture({ gesture }),
        },
        h,
      ),
      h.div(
        [h.Class('e3-controls')],
        [
          h.label(
            [h.Class('e3-note')],
            [
              'Write on the wall ',
              h.input([
                h.Class('e3-text'),
                h.Type('text'),
                h.Maxlength(noteLimit),
                h.Value(model.note),
                h.OnInput((note) => Message.ChangedNote({ note })),
              ]),
            ],
          ),
          button(h, model.lit ? 'Lights off' : 'Lights on', Message.ToggledLights()),
        ],
      ),
      h.p(
        [h.Class('e3-readout'), h.AriaLive('polite')],
        [`Your note is laid out ${width(model, 'note:text')} units; its panel follows.`],
      ),
      h.p(
        [h.Class('e3-note')],
        [
          'Every word here is a troika label described as data. The title follows the wall’s curve, ',
          'the code is coloured by colour ranges, and with the lights on the labels take a lit ',
          'material. Each panel is sized from its label’s measured layout, reported back as a ',
          'Message. Click a plaque to walk up to it; drag to look around.',
        ],
      ),
    ],
  ),
});