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