โ† All examples

Bars

Monthly activity

Four months. Three teams. Parts, groups and totals.

Illustrative data.

Source

app.ts

import { lazyApp } from '@opsydyn/astro-foldkit/define-app';

import type { Props } from './model';
export default lazyApp<Props & { readonly noMeta?: boolean }>(() => import('./main'));

data.ts

export const barData = ['Jan', 'Feb', 'Mar', 'Apr'].flatMap((month, i) =>
  ['Core', 'Tools', 'Community'].map((series, j) => ({
    id: `${month}-${series}`,
    month,
    series,
    value: 12 + ((i * 13 + j * 19) % 45),
  })),
);
export const barAccessors = {
  key: (d: (typeof barData)[number]) => d.id,
  category: (d: (typeof barData)[number]) => d.month,
  series: (d: (typeof barData)[number]) => d.series,
  value: (d: (typeof barData)[number]) => d.value,
};

main.ts

export { Model, init } from './model';
export { Message } from './message';
export { update } from './update';
export { view } from './view';

message.ts

import { Schema } from 'effect';
import { defineMessageUnion } from 'foldkit/message';

import { Mode, Orientation } from './model';
export const Message = defineMessageUnion({
  SelectedMode: { mode: Mode },
  SelectedOrientation: { orientation: Orientation },
  SelectedPaint: { paint: Schema.Literals(['solid', 'dots', 'hatch', 'gradient']) },
  RecordedWidth: { width: Schema.Number },
  InspectedBar: { key: Schema.String },
  ClickedReset: {},
});
export type Message = typeof Message.Type;

model.ts

import { Schema } from 'effect';
export const Mode = Schema.Literals(['grouped', 'stacked']);
export const Orientation = Schema.Literals(['vertical', 'horizontal']);
export const Datum = Schema.Struct({
  id: Schema.String,
  month: Schema.String,
  series: Schema.String,
  value: Schema.Number,
});
export const Props = Schema.Struct({ data: Schema.Array(Datum) });
export type Props = typeof Props.Type;
export const Model = Schema.Struct({
  data: Schema.Array(Datum),
  mode: Mode,
  orientation: Orientation,
  width: Schema.Number,
  activeKey: Schema.NullOr(Schema.String),
  paint: Schema.Literals(['solid', 'dots', 'hatch', 'gradient']),
});
export type Model = typeof Model.Type;
export interface InitReturn {
  readonly model: Model;
}
export const init = (props: Props): InitReturn => ({
  model: {
    ...props,
    mode: 'grouped',
    orientation: 'vertical',
    width: 700,
    activeKey: null,
    paint: 'solid',
  },
});

update.ts

import type { Return } from 'foldkit/update';

import { Message } from './message';
import type { Model } from './model';
export const update = (model: Model, message: Message): Return<Model, Message> =>
  Message.match(message, {
    SelectedMode: ({ mode }) => ({ model: { ...model, mode } }),
    SelectedOrientation: ({ orientation }) => ({ model: { ...model, orientation } }),
    SelectedPaint: ({ paint }) => ({ model: { ...model, paint } }),
    RecordedWidth: ({ width }) => ({
      model: Number.isFinite(width) && width >= 220 ? { ...model, width } : model,
    }),
    InspectedBar: ({ key }) => ({
      model: model.data.some((d) => d.id === key) ? { ...model, activeKey: key } : model,
    }),
    ClickedReset: (): Return<Model, Message> => ({
      model: {
        ...model,
        mode: 'grouped',
        orientation: 'vertical',
        activeKey: null,
        paint: 'solid',
      },
    }),
  });

view.ts

import { barGeometry } from '@opsydyn/foldkit-viz/chart/bars';
import { dotPattern, hatchPattern, linearGradient } from '@opsydyn/foldkit-viz/foldkit/paint';
import { Stream } from 'effect';
import { Mount } from 'foldkit';
import type { Document, HtmlBuilder } from 'foldkit/html';

import { widthChanges } from '../shared/measurement';
import { barAccessors } from './data';
import { Message } from './message';
import type { Model } from './model';
const MeasureBars = Mount.defineStream('MeasureBars', {
  messages: [Message.RecordedWidth],
  execute: ({ element }) =>
    widthChanges(element).pipe(Stream.map((width) => Message.RecordedWidth({ width }))),
});
const palette = ['var(--blue)', 'var(--coral)', 'var(--mint)'];
export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
  const geometry = barGeometry(model.data, barAccessors, {
    frame: {
      width: model.width,
      height: 350,
      margins: { top: 24, right: 24, bottom: 42, left: 48 },
    },
    mode: model.mode,
    orientation: model.orientation,
  });
  const active = model.data.find((d) => d.id === model.activeKey);
  const source = `import { barGeometry } from '@opsydyn/foldkit-viz/chart/bars';\n\nconst geometry = barGeometry(data, {\n  key: d => d.id, category: d => d.month,\n  series: d => d.series, value: d => d.value,\n}, {\n  frame, mode: '${model.mode}',\n  orientation: '${model.orientation}',\n});\n// Render geometry.bars as SVG rectangles.\n// Choose each series' paint in your view.\n`;
  return {
    title: 'Grouped and stacked bars โ€” Foldkit Viz',
    body: h.div(
      [h.Class('primitive-demo')],
      [
        h.div(
          [h.Class('primitive-controls')],
          [
            h.fieldset(
              [],
              [
                h.legend([], ['Composition']),
                ...(['grouped', 'stacked'] as const).map((mode) =>
                  h.button(
                    [
                      h.Type('button'),
                      h.AriaPressed(String(model.mode === mode)),
                      h.OnClick(Message.SelectedMode({ mode })),
                    ],
                    [mode],
                  ),
                ),
              ],
            ),
            h.fieldset(
              [],
              [
                h.legend([], ['Orientation']),
                ...(['vertical', 'horizontal'] as const).map((orientation) =>
                  h.button(
                    [
                      h.Type('button'),
                      h.AriaPressed(String(model.orientation === orientation)),
                      h.OnClick(Message.SelectedOrientation({ orientation })),
                    ],
                    [orientation],
                  ),
                ),
              ],
            ),
            h.fieldset(
              [],
              [
                h.legend([], ['Paint']),
                ...(['solid', 'dots', 'hatch', 'gradient'] as const).map((paint) =>
                  h.button(
                    [
                      h.Type('button'),
                      h.AriaPressed(String(model.paint === paint)),
                      h.OnClick(Message.SelectedPaint({ paint })),
                    ],
                    [paint],
                  ),
                ),
              ],
            ),
            h.button([h.Type('button'), h.OnClick(Message.ClickedReset())], ['Reset']),
          ],
        ),
        h.div(
          [h.Class('primitive-plot'), h.OnMount(MeasureBars())],
          [
            h.svg(
              [
                h.ViewBox(`0 0 ${model.width} 350`),
                h.Role('group'),
                h.AriaLabel('Illustrative monthly activity by series'),
              ],
              [
                h.defs(
                  [],
                  geometry.series.flatMap((_series, i) => {
                    const colour = palette[i % palette.length] ?? 'currentColor';
                    return [
                      dotPattern(h, { id: `bars-dots-${i}`, colour, spacing: 7, radius: 1.6 }),
                      hatchPattern(h, {
                        id: `bars-hatch-${i}`,
                        colour,
                        spacing: 6,
                        strokeWidth: 2,
                      }),
                      linearGradient(h, {
                        id: `bars-gradient-${i}`,
                        stops: [
                          { offset: 0, colour, opacity: 0.35 },
                          { offset: 1, colour },
                        ],
                      }),
                    ];
                  }),
                ),
                ...geometry.ticks.flatMap((t) =>
                  model.orientation === 'vertical'
                    ? [
                        h.line(
                          [
                            h.X1(String(geometry.plot.left)),
                            h.X2(String(geometry.plot.right)),
                            h.Y1(String(t.position)),
                            h.Y2(String(t.position)),
                            h.Stroke('var(--border)'),
                          ],
                          [],
                        ),
                        h.text(
                          [h.X('38'), h.Y(String(t.position + 4)), h.TextAnchor('end')],
                          [String(t.value)],
                        ),
                      ]
                    : [
                        h.line(
                          [
                            h.X1(String(t.position)),
                            h.X2(String(t.position)),
                            h.Y1(String(geometry.plot.top)),
                            h.Y2(String(geometry.plot.bottom)),
                            h.Stroke('var(--border)'),
                          ],
                          [],
                        ),
                        h.text(
                          [h.X(String(t.position)), h.Y('333'), h.TextAnchor('middle')],
                          [String(t.value)],
                        ),
                      ],
                ),
                ...geometry.bars.map((b) => {
                  const i = geometry.series.indexOf(b.series);
                  const fill =
                    model.paint === 'solid'
                      ? (palette[i % palette.length] ?? 'currentColor')
                      : `url(#bars-${model.paint}-${i})`;
                  return h.rect(
                    [
                      h.Key(b.key),
                      h.X(String(b.x)),
                      h.Y(String(b.y)),
                      h.Width(String(b.width)),
                      h.Height(String(b.height)),
                      h.Fill(fill),
                      h.Stroke(b.key === model.activeKey ? 'var(--text)' : 'none'),
                      h.StrokeWidth('2'),
                      h.Tabindex(0),
                      h.Role('img'),
                      h.AriaLabel(`${b.category}, ${b.series}: ${b.value}`),
                      h.OnFocus(Message.InspectedBar({ key: b.key })),
                      h.OnMouseEnter(Message.InspectedBar({ key: b.key })),
                    ],
                    [h.title([], [`${b.category}, ${b.series}: ${b.value}`])],
                  );
                }),
                ...geometry.categories.map((c) =>
                  model.orientation === 'vertical'
                    ? h.text([h.X(String(c.position)), h.Y('333'), h.TextAnchor('middle')], [c.key])
                    : h.text(
                        [h.X('38'), h.Y(String(c.position + 4)), h.TextAnchor('end')],
                        [c.key],
                      ),
                ),
              ],
            ),
          ],
        ),
        h.p(
          [h.Class('primitive-status'), h.AriaLive('polite')],
          [active ? `${active.month} ยท ${active.series}: ${active.value}` : 'No bar selected.'],
        ),
        h.ul(
          [h.Class('primitive-legend'), h.AriaLabel('Series')],
          geometry.series.map((s, i) =>
            h.li(
              [],
              [
                h.span(
                  [
                    h.Class('primitive-series-swatch'),
                    h.AriaHidden(true),
                    h.Style({ backgroundColor: palette[i % palette.length] ?? 'currentColor' }),
                  ],
                  [],
                ),
                s,
              ],
            ),
          ),
        ),
        h.details(
          [],
          [h.summary([], ['Read the geometry code']), h.pre([], [h.code([], [source])])],
        ),
        h.details(
          [],
          [
            h.summary([], ['View all data']),
            h.table(
              [],
              [
                h.caption([], ['Illustrative monthly activity']),
                h.thead(
                  [],
                  [
                    h.tr(
                      [],
                      ['Month', 'Series', 'Value'].map((s) => h.th([h.Scope('col')], [s])),
                    ),
                  ],
                ),
                h.tbody(
                  [],
                  model.data.map((d) =>
                    h.tr(
                      [],
                      [h.td([], [d.month]), h.td([], [d.series]), h.td([], [String(d.value)])],
                    ),
                  ),
                ),
              ],
            ),
          ],
        ),
      ],
    ),
  };
};