npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mim/histui

v0.3.8

Published

Reusable Histui interactive timeline package for PastStruct and normalized historical records.

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 API
  • src/index.d.ts - public TypeScript declarations
  • src/styles.css - required component styles
  • src/timeline-view.js - low-level timeline renderer
  • src/time-scale.js - piecewise year to unit mapping used by time breaks
  • src/paststruct.js - PastStruct normalization helpers
  • examples/basic.html - no-build browser example

Basic Usage

Install the package from npm:

npm install @mim/histui

Import 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 dev

Open 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 dev

Keep 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 with DEFAULT_HISTUI_CONFIG.
  • language: default "en".
  • direction: optional text direction override.
  • themeId or theme: 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. Default true.
  • replace: clear the container before mounting. Default true.
  • 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 for config.timeline.timeBreaks.
  • measurement: optional override for config.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 it

Keyboard

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 record

Check

npm run check

Publishing

See PUBLISHING.md for the npm publishing checklist.