← All examples

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