@mim/histui
v0.3.8
Published
Reusable Histui interactive timeline package for PastStruct and normalized historical records.
Maintainers
Readme
Histui
Histui is a reusable, framework-agnostic interactive history timeline package. It can render PastStruct datasets or already-normalized records into a zoomable, pannable, responsive timeline with LOD, clustering, a zoom navigator, hover-linked connectors, blueprint-style measurement indicators, axis placement controls, zoom-aware time breaks, keyboard record stepping, themes, broadcast display mode, Persian/English UI strings, and explode mode.
Files
src/index.js- public package APIsrc/index.d.ts- public TypeScript declarationssrc/styles.css- required component stylessrc/timeline-view.js- low-level timeline renderersrc/time-scale.js- piecewise year to unit mapping used by time breakssrc/paststruct.js- PastStruct normalization helpersexamples/basic.html- no-build browser example
Basic Usage
Install the package from npm:
npm install @mim/histuiImport the JavaScript API and required stylesheet:
import { createHistuiTimeline } from "@mim/histui";
import "@mim/histui/styles.css";
const histui = createHistuiTimeline({
container: "#timeline",
data: pastStructDataset,
language: "en",
themeId: "obsidian-lab",
displayMode: "standard",
explodeEnabled: false,
onSelect(record) {
console.log("selected", record.id);
},
onViewportChange(viewport) {
console.log(viewport);
}
});
histui.setExplodeEnabled(true);
histui.setFilters({ minSignificance: 7 });Local Development
Run the package directly from this repository when you want to develop histui itself and see source changes immediately in the browser:
npm run devOpen http://127.0.0.1:5175. The dev server serves examples/basic.html, which imports ../src/index.js and ../src/styles.css, so it always uses the local package source instead of a published build. Its dataset leaves two long empty stretches and one nine-century period with nothing inside it, and a strip above the timeline picks the break label so every mode can be compared against the built-in Skip empty time toggle.
Changes in src/, examples/, README.md, PUBLISHING.md, or package.json trigger an automatic browser reload. The server disables caching so style and JavaScript edits show up on the next reload without extra build steps.
Use a custom port when needed:
PORT=5180 npm run devKeep this server running while editing package files. For testing the package inside histui-app-2, run npm run histui:local in the app repo to point @mim/histui at ../histui, then run npm run histui:published when you want to switch the app back to the published package.
Public API
import {
HistuiTimeline,
createHistuiTimeline,
normalizeTimelineData,
normalizePastStruct,
createDefaultFilters,
filterRecords,
DEFAULT_HISTUI_CONFIG
} from "@mim/histui";createHistuiTimeline(options)
Creates and mounts a timeline instance.
Common options:
container: CSS selector or element. Required.data: PastStruct dataset document, single PastStruct record, or array of records.records: normalized records or raw PastStruct record array.config: partial config merged withDEFAULT_HISTUI_CONFIG.language: default"en".direction: optional text direction override.themeIdortheme: built-in theme id or custom theme object.displayMode:"standard"or"broadcast". Broadcast mode increases legibility and uses overlay-friendly timeline styling.controls: render built-in timeline controls. Defaulttrue.replace: clear the container before mounting. Defaulttrue.filters: initial filter object.orientation:"auto","horizontal", or"vertical".axisPlacement:{ horizontal, vertical }, each"center","side-start", or"side-end".lodEnabled: boolean.explodeEnabled: boolean.timeBreaksEnabled: boolean. Collapse empty stretches of time. See Time Breaks.timeBreaks: partial override forconfig.timeline.timeBreaks.measurement: optional override forconfig.timeline.measurement.analytics.measurementId: optional Google Analytics measurement id.onSelect(record, instance): event callback.onViewportChange(viewport, instance): event callback.onRecordsChange(records, instance): event callback.onTrack(name, payload, instance): analytics/telemetry callback.
Instance Methods
setData(data, options)setRecords(records, options)setFilters(filters, options)resetFilters(options)select(recordId, options)focusRecord(recordId, options)stepSelection(direction)fit(options)zoomBy(factor)setViewRange(start, end, options)setOrientation(orientation)setAxisPlacement(orientation, placement)setLodEnabled(enabled)setExplodeEnabled(enabled)setTimeBreaksEnabled(enabled)setTimeBreakOptions(options)setMeasurementOptions(options)setMeasurementEnabled(enabled)setDisplayMode("standard" | "broadcast")setBroadcastMode(enabled)setLanguage(language, direction)setTheme(themeOrId)getState()destroy()
Filters
setFilters() accepts the same filter shape used internally:
histui.setFilters({
search: "revolution",
recordTypes: ["event", "period"],
types: ["political"],
minSignificance: 6,
mediaOnly: false,
uncertainOnly: false,
fromYear: 1800,
toYear: 2026
});Set-like fields can be arrays or Set instances.
Config
The package exposes DEFAULT_HISTUI_CONFIG. You can override only the keys you need:
createHistuiTimeline({
container,
data,
config: {
timeline: {
measurement: {
enabled: true,
transient: true,
fadeOutMs: 1200
},
explode: {
maxVisible: 42,
layers: 8,
animationMs: 700
}
}
}
});timeline.measurement.enabled draws a dimension-style line across the currently visible timeline span and labels it with the visible year count. Set timeline.measurement.transient to true to show it only after the viewport changes; it fades out after fadeOutMs milliseconds, defaulting to 1200.
Time Breaks
Datasets that mix antiquity with the modern era leave huge empty stretches on a linear axis, so reaching the next record means panning through centuries of nothing. Time breaks collapse those empty stretches into short marked segments, which keeps the records spread across the frame at every zoom level.
A break goes wherever nothing starts and nothing ends for long enough. That includes the inside of a single long record: a period of a thousand quiet years is just as much panning as an empty one, so the axis is cut there too and the record's own line keeps running across the cut, which is what shows it never ended. Those cuts are marked as ongoing, they say quiet rather than gap, and their band is left transparent so the line stays visible. Set breakOngoing: false to cut only where the axis is truly empty.
const histui = createHistuiTimeline({
container: "#timeline",
data: pastStructDataset,
timeBreaksEnabled: true
});
histui.setTimeBreaksEnabled(false);
histui.setTimeBreakOptions({ minGapRatio: 0.1, collapsedRatio: 0.01, label: "range" });The built-in controls include a Skip empty time toggle. Each collapsed gap is drawn as a hatched band with zigzag edges and a small muted chip. Hovering a chip shows the full picture — the empty span, the years on both sides, and how much the axis dropped — and brings the chip itself to full contrast; clicking it expands that one gap back to real scale, and clicking again collapses it. Expanded gaps stay expanded until the toggle, the dataset, or the cut itself changes; changing only the chip text leaves them open.
label chooses what a chip says, and defaults to "gap". Unknown values fall back to that default, and the hover tooltip carries every number regardless, so "none" loses no information.
| label | Chip | Reads |
| --- | --- | --- |
| "gap" | 2,220 yr gap | Time between the records on either side: from where the earlier one ends to where the later one starts. Reads 2,220 yr quiet when a record runs across the cut. |
| "removed" | ~2,186 yr | Time taken off the axis, which is a little shorter because a break keeps some context and the collapsed band still occupies a sliver. |
| "both" | ~2,186 of 2,220 yr | Both. |
| "range" | 321 BCE - 1900 CE | Where the cut sits, rather than how long it is. |
| "none" | ⌇ | Only the glyph. |
Chips spell the years out, because compact notation would round gap and removed to the same figure; only spans past 100,000 years, which means deep time rather than history, fall back to a compact form.
Breaks follow the zoom. Whether a stretch feels long depends on how much of the view it takes, not on how many years it holds: 300 quiet years are invisible across a millennium and half the screen across a century. A stretch is therefore cut once it passes minGapRatio of the time currently on screen, and the axis is re-cut whenever the zoom moves by more than zoomSyncRatio, so zooming in keeps cutting the newly dominant stretches instead of reopening the quiet runs. Panning never re-cuts anything.
The time on screen is measured in real years, not in the axis units the cuts shorten. That distinction is what keeps deep zoom working: judging a stretch against the shortened axis would let every cut lower the bar for the next one, and the cascade would strip the axis of the dense time zooming in is meant to resolve. How much room a cut takes is still a share of the frame in units, so a tight frame keeps a thin band. Because that reading jumps whenever a cut appears, each re-cut blends it with the reach the map was last cut for, which settles on one answer rather than alternating between two.
Behaviour is tuned through config.timeline.timeBreaks:
| Option | Default | Meaning |
| --- | --- | --- |
| enabled | false | Collapse empty time by default. |
| label | "gap" | What each chip reports: "gap", "removed", "both", "range", or "none". |
| breakOngoing | true | Also cut quiet stretches a record runs across, such as the middle of a long period. |
| minGapRatio | 0.12 | Minimum stretch size, as a fraction of the years on screen, before it can be collapsed. |
| minGapYears | 0 | Absolute floor in years for a collapsible stretch. |
| collapsedRatio | 0.022 | Width each collapsed stretch keeps, as a fraction of the frame in axis units. |
| contextRatio | 0.12 | Share of a gap left uncollapsed on each side, so records keep breathing room. |
| maxBreaks | 240 | Largest number of gaps to collapse; the widest gaps win. |
| zoomSyncRatio | 0.18 | How far the zoom must move before the axis is re-cut. Larger values re-cut less often. |
Gaps are computed from the start and end years of the filtered records, so filtering a record out can open new cuts. A re-cut keeps the zoom level and the year under the pointer fixed, so the pixels per year of dense time never jump; what changes is how much time the frame reaches, since collapsing a gap pulls the next records closer.
Years stay the unit of the public API: setViewRange(), filters.fromYear, axis labels, and the start, end, and span fields of onViewportChange are all real years. The viewport payload also reports timeBreaksEnabled, breaks (collapsed gaps currently in view), breakCount (collapsed gaps in the whole map), skippedYears, and compressedSpan for the internal compressed span.
The mapping itself is exported for custom axes or tooling:
import {
buildTimeScale,
TimeScale,
normalizeTimeBreakOptions,
DEFAULT_TIME_BREAK_OPTIONS,
TIME_BREAK_LABELS
} from "@mim/histui";
// The fourth argument is the zoom level: `viewSpan` is how many units fit in the frame,
// `viewYears` how much history those units reach across. They differ once cuts exist, and
// `viewYears` defaults to `viewSpan`. Omit both to cut as if the whole dataset were in view.
const scale = buildTimeScale(
records,
{ start: -3000, end: 2026 },
{ enabled: true },
{ viewSpan: 400, viewYears: 1800 }
);
scale.toUnit(1979); // compressed coordinate
scale.toYear(120); // back to a year
scale.breaks; // collapsed segments
scale.breaks[0]?.gapYears; // the quiet stretch behind a break, context included
scale.breaks[0]?.ongoing; // true when a record runs across itKeyboard
The timeline stage takes focus when clicked, and answers:
| Key | Action |
| --- | --- |
| → / ↓ | Select the next record in time and travel to it. |
| ← / ↑ | Select the previous record and travel to it. |
| Shift + arrows | Pan the viewport without changing the selection. |
| + / - | Zoom in and out. |
| Home | Fit the whole dataset. |
Stepping keeps the current zoom, centres the record, and eases in and out of the move; the travel time grows with the distance covered, up to about twice config.timeline.keyboardStepMs (default 460). Records longer than the frame widen it just enough to fit. Because breaks compress the empty time in between, a step across a collapsed gap is a short move rather than a long slide.
The same walk is available programmatically:
histui.stepSelection(1); // next record
histui.stepSelection(-1); // previous record
histui.focusRecord("record-id"); // select and travel to a specific recordCheck
npm run checkPublishing
See PUBLISHING.md for the npm publishing checklist.
