LEARN BY CHANGING
Give words weight.
A measured, deterministic layout. Change the spiral, font, rotation and spacing. Foldkit keeps controls explicit; pure TypeScript finds space for each word.
Measured once. Composed freely.
Font measurement runs in a Command. The layout accepts those measurements and returns SVG coordinates. Conservative bounding boxes keep words apart; words that cannot fit are reported.
Explore the layout source →See how it works.
The source running this example. Expand a file to follow the Model, Messages and view.
data.ts
export const words = [
['Geometry', 64],
['Foldkit', 55],
['TypeScript', 48],
['Compose', 43],
['SVG', 40],
['State', 36],
['Effect', 33],
['Scales', 30],
['Shape', 28],
['Interaction', 26],
['Themes', 24],
['Data', 22],
['Messages', 20],
['Explicit', 18],
['Colour', 16],
['Layout', 15],
['Measure', 14],
['Trace', 13],
['Test', 12],
['Extend', 11],
['Explore', 10],
['Accessible', 9],
['Pure', 8],
['Primitives', 7],
['Control', 6],
].map(([text, value], index) => ({ id: String(index), text: String(text), value: Number(value) }));
domain.ts
import { Schema } from 'effect';
export const Word = Schema.Struct({ id: Schema.String, text: Schema.String, value: Schema.Number });
export type Word = typeof Word.Type;
export const Font = Schema.Literals(['inter', 'monospace']);
export const Spiral = Schema.Literals(['archimedean', 'rectangular']);
export const Settings = Schema.Struct({
font: Font,
spiral: Spiral,
rotate: Schema.Boolean,
padding: Schema.Number,
size: Schema.Number,
});
export type Settings = typeof Settings.Type;
export const Metric = Schema.Struct({
id: Schema.String,
text: Schema.String,
value: Schema.Number,
size: Schema.Number,
width: Schema.Number,
height: Schema.Number,
xOffset: Schema.Number,
yOffset: Schema.Number,
});
export type Metric = typeof Metric.Type;
const families = { inter: '"Inter Variable", sans-serif', monospace: 'monospace' };
export const fontFamily = (font: typeof Font.Type): string => families[font];
export const initialSettings: Settings = {
font: 'inter',
spiral: 'archimedean',
rotate: false,
padding: 3,
size: 54,
};
model.ts
import { Schema } from 'effect';
import { defineTaggedUnion } from 'foldkit/schema';
import type { Return } from 'foldkit/update';
import { MeasureWords } from './command';
import { initialSettings, Metric, Settings, Word } from './domain';
import type { Message } from './message';
export const Measurement = defineTaggedUnion({
Loading: {},
Ready: { words: Schema.Array(Metric) },
Failed: { error: Schema.String },
});
export const Props = Schema.Struct({ words: Schema.Array(Word) });
export type Props = typeof Props.Type;
export const Model = Schema.Struct({
words: Schema.Array(Word),
settings: Settings,
measurement: Measurement,
revision: Schema.Number,
width: Schema.Number,
activeKey: Schema.NullOr(Schema.String),
});
export type Model = typeof Model.Type;
export const init = (
props: Props,
): Return<Model, Message> & {
readonly commands: NonNullable<Return<Model, Message>['commands']>;
} => ({
model: {
...props,
settings: initialSettings,
measurement: Measurement.Loading(),
revision: 1,
width: 700,
activeKey: null,
},
commands: [MeasureWords({ revision: 1, words: props.words, settings: initialSettings })],
});
message.ts
import { Schema } from 'effect';
import { defineMessageUnion } from 'foldkit/message';
import { Font, Metric, Spiral } from './domain';
export const Message = defineMessageUnion({
SucceededMeasurement: { revision: Schema.Number, measurements: Schema.Array(Metric) },
FailedMeasurement: { revision: Schema.Number, error: Schema.String },
SelectedSpiral: { spiral: Spiral },
SelectedFont: { font: Font },
SelectedRotation: { rotate: Schema.Boolean },
ChangedPadding: { value: Schema.String },
ChangedSize: { value: Schema.String },
RecordedWidth: { width: Schema.Number },
InspectedWord: { key: Schema.String },
ClickedRetry: {},
ClickedReset: {},
});
export type Message = typeof Message.Type;
command.ts
import { Data, Effect, Option, Schema } from 'effect';
import { Command } from 'foldkit';
import { fontFamily, Settings, Word } from './domain';
import { Message } from './message';
class TextMeasurementError extends Data.TaggedError('TextMeasurementError')<{}> {}
function measureWord(
context: CanvasRenderingContext2D,
word: typeof Word.Type,
size: number,
family: string,
): typeof import('./domain').Metric.Type {
context.font = `600 ${size}px ${family}`;
context.textAlign = 'center';
const metric = context.measureText(word.text);
const width = Math.max(
metric.width,
metric.actualBoundingBoxLeft + metric.actualBoundingBoxRight,
);
const height = Math.max(
size * 0.5,
metric.actualBoundingBoxAscent + metric.actualBoundingBoxDescent,
);
return {
...word,
size,
width,
height,
xOffset: (metric.actualBoundingBoxLeft - metric.actualBoundingBoxRight) / 2,
yOffset: (metric.actualBoundingBoxAscent - metric.actualBoundingBoxDescent) / 2,
};
}
export const MeasureWords = Command.define('MeasureWords', {
args: { revision: Schema.Number, words: Schema.Array(Word), settings: Settings },
messages: [Message.SucceededMeasurement, Message.FailedMeasurement],
execute: ({ revision, words, settings }) =>
Effect.tryPromise({
try: async () => {
const family = fontFamily(settings.font);
await document.fonts.load(`600 ${settings.size}px ${family}`);
await document.fonts.ready;
const context = Option.getOrThrowWith(
Option.fromNullishOr(document.createElement('canvas').getContext('2d')),
() => new TextMeasurementError(),
);
const max = Math.max(1, ...words.map((d) => d.value));
const measurements = words.map((word) =>
measureWord(
context,
word,
12 + Math.sqrt(Math.max(0, word.value) / max) * (settings.size - 12),
family,
),
);
return Message.SucceededMeasurement({ revision, measurements });
},
catch: () => 'Could not measure this font. Retry or choose another font.',
}).pipe(
Effect.match({
onSuccess: (message) => message,
onFailure: (error) => Message.FailedMeasurement({ revision, error }),
}),
),
});
update.ts
import type { Return } from 'foldkit/update';
import { MeasureWords } from './command';
import { initialSettings } from './domain';
import type { Settings } from './domain';
import { Message } from './message';
import { Measurement } from './model';
import type { Model } from './model';
const remeasure = (model: Model, settings: Settings): Return<Model, Message> => {
const revision = model.revision + 1;
return {
model: { ...model, settings, revision, activeKey: null, measurement: Measurement.Loading() },
commands: [MeasureWords({ revision, words: model.words, settings })],
};
};
export const update = (model: Model, message: Message): Return<Model, Message> =>
Message.match(message, {
SucceededMeasurement: ({ revision, measurements }) => ({
model:
revision === model.revision
? { ...model, measurement: Measurement.Ready({ words: measurements }) }
: model,
}),
FailedMeasurement: ({ revision, error }) => ({
model:
revision === model.revision
? { ...model, measurement: Measurement.Failed({ error }) }
: model,
}),
SelectedSpiral: ({ spiral }) => ({
model: { ...model, settings: { ...model.settings, spiral }, activeKey: null },
}),
SelectedRotation: ({ rotate }) => ({
model: { ...model, settings: { ...model.settings, rotate }, activeKey: null },
}),
SelectedFont: ({ font }) =>
font === model.settings.font ? { model } : remeasure(model, { ...model.settings, font }),
ChangedPadding: ({ value }) => {
const padding = Number(value);
return value.trim() && Number.isInteger(padding) && padding >= 0 && padding <= 12
? { model: { ...model, settings: { ...model.settings, padding }, activeKey: null } }
: { model };
},
ChangedSize: ({ value }) => {
const size = Number(value);
return value.trim() && Number.isInteger(size) && size >= 24 && size <= 80
? remeasure(model, { ...model.settings, size })
: { model };
},
RecordedWidth: ({ width }) => ({
model: Number.isFinite(width) && width >= 220 ? { ...model, width, activeKey: null } : model,
}),
InspectedWord: ({ key }) => ({
model: model.words.some((d) => d.id === key) ? { ...model, activeKey: key } : model,
}),
ClickedRetry: () => remeasure(model, model.settings),
ClickedReset: () => remeasure(model, initialSettings),
});
view.ts
import { wordCloud } from '@opsydyn/foldkit-viz/layout/wordcloud';
import { Stream } from 'effect';
import { Mount } from 'foldkit';
import type { Document, HtmlBuilder } from 'foldkit/html';
import { widthChanges } from '../shared/measurement';
import { fontFamily } from './domain';
import { Message } from './message';
import { Measurement } from './model';
import type { Model } from './model';
const MeasureCloudWidth = Mount.defineStream('MeasureCloudWidth', {
messages: [Message.RecordedWidth],
execute: ({ element }) =>
widthChanges(element).pipe(Stream.map((width) => Message.RecordedWidth({ width }))),
});
const palette = [
'var(--word-blue)',
'var(--word-coral)',
'var(--word-mint)',
'var(--word-gold)',
'var(--word-lavender)',
];
export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
const chart = Measurement.match(model.measurement, {
Loading: () => h.p([h.Class('primitive-placeholder'), h.Role('status')], ['Measuring text…']),
Failed: ({ error }) =>
h.div(
[h.Class('primitive-placeholder')],
[
h.p([h.Role('alert')], [error]),
h.button([h.Type('button'), h.OnClick(Message.ClickedRetry())], ['Retry measurement']),
],
),
Ready: ({ words }) => {
const cloud = wordCloud(
words,
{
key: (d) => d.id,
text: (d) => d.text,
width: (d) => d.width,
height: (d) => d.height,
rotation: (_d, i) => (model.settings.rotate && i % 3 === 1 ? 90 : 0),
},
{
width: model.width,
height: 350,
padding: model.settings.padding,
spiral: model.settings.spiral,
},
);
return h.div(
[],
[
h.svg(
[
h.ViewBox(`0 0 ${model.width} 350`),
h.Role('img'),
h.AriaLabel('Illustrative visualisation vocabulary, sized by weight'),
],
[
h.desc(
[],
['Words are placed using measured font bounds. Focus a word to read its weight.'],
),
...cloud.words.map((word) =>
h.g(
[
h.Key(word.key),
h.Transform(`translate(${word.x},${word.y}) rotate(${word.rotation})`),
],
[
h.text(
[
h.X(String(word.datum.xOffset)),
h.Y(String(word.datum.yOffset)),
h.FontFamily(fontFamily(model.settings.font)),
h.FontSize(String(word.datum.size)),
h.FontWeight('600'),
h.TextAnchor('middle'),
h.Fill(
palette[
model.words.findIndex((d) => d.id === word.key) % palette.length
] ?? 'currentColor',
),
h.Tabindex(0),
h.AriaLabel(`${word.text}: weight ${word.datum.value}`),
h.OnFocus(Message.InspectedWord({ key: word.key })),
h.OnMouseEnter(Message.InspectedWord({ key: word.key })),
h.Class(word.key === model.activeKey ? 'cloud-word active' : 'cloud-word'),
],
[word.text],
),
],
),
),
],
),
h.p(
[h.Class('primitive-status'), h.AriaLive('polite')],
[
`${cloud.words.length} of ${words.length} words placed.`,
...(cloud.omitted.length
? [` ${cloud.omitted.length} could not fit. Reduce size or padding to make room.`]
: []),
],
),
],
);
},
});
const active = model.words.find((d) => d.id === model.activeKey);
const source = `import { wordCloud } from '@opsydyn/foldkit-viz/layout/wordcloud';\n\n// A Command measures each word after the font loads.\nconst cloud = wordCloud(measuredWords, {\n key: d => d.id, text: d => d.text,\n width: d => d.width, height: d => d.height,\n rotation: (d, i) => ${model.settings.rotate ? '(i % 3 === 1 ? 90 : 0)' : '0'},\n}, { width: ${Math.round(model.width)}, height: 350,\n padding: ${model.settings.padding}, spiral: '${model.settings.spiral}',\n});\n// Font: ${fontFamily(model.settings.font)}\n// Largest word: ${model.settings.size}px\n// Render cloud.words as SVG text; report cloud.omitted.\n`;
return {
title: 'Word cloud — Foldkit Viz',
body: h.div(
[h.Class('primitive-demo')],
[
h.div(
[h.Class('primitive-controls')],
[
h.fieldset(
[],
[
h.legend([], ['Spiral']),
...(['archimedean', 'rectangular'] as const).map((spiral) =>
h.button(
[
h.Type('button'),
h.AriaPressed(String(model.settings.spiral === spiral)),
h.OnClick(Message.SelectedSpiral({ spiral })),
],
[spiral],
),
),
],
),
h.fieldset(
[],
[
h.legend([], ['Font']),
...(['inter', 'monospace'] as const).map((font) =>
h.button(
[
h.Type('button'),
h.AriaPressed(String(model.settings.font === font)),
h.OnClick(Message.SelectedFont({ font })),
],
[font],
),
),
],
),
h.label(
[h.Class('primitive-checkbox')],
[
h.input([
h.Type('checkbox'),
h.Checked(model.settings.rotate),
h.OnClick(Message.SelectedRotation({ rotate: !model.settings.rotate })),
]),
'Rotate words',
],
),
h.label(
[],
[
'Padding ',
h.input([
h.Type('range'),
h.Min('0'),
h.Max('12'),
h.Step('1'),
h.Value(String(model.settings.padding)),
h.OnInput((value) => Message.ChangedPadding({ value })),
]),
` ${model.settings.padding}px`,
],
),
h.label(
[],
[
'Largest word ',
h.input([
h.Type('range'),
h.Min('24'),
h.Max('80'),
h.Step('1'),
h.Value(String(model.settings.size)),
h.OnInput((value) => Message.ChangedSize({ value })),
]),
` ${model.settings.size}px`,
],
),
h.button([h.Type('button'), h.OnClick(Message.ClickedReset())], ['Reset']),
],
),
h.div([h.Class('primitive-plot'), h.OnMount(MeasureCloudWidth())], [chart]),
h.p(
[h.Class('primitive-status'), h.AriaLive('polite')],
[
active
? `${active.text} · weight ${active.value}`
: 'Hover or focus a word to inspect its weight.',
],
),
h.details([], [h.summary([], ['Read the layout code']), h.pre([], [h.code([], [source])])]),
h.details(
[],
[
h.summary([], ['View all data']),
h.table(
[],
[
h.caption([], ['Illustrative vocabulary weights']),
h.thead(
[],
[
h.tr(
[],
[h.th([h.Scope('col')], ['Word']), h.th([h.Scope('col')], ['Weight'])],
),
],
),
h.tbody(
[],
model.words.map((word) =>
h.tr([], [h.td([], [word.text]), h.td([], [String(word.value)])]),
),
),
],
),
],
),
],
),
};
};