@mim/histui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # Histui
2
+
3
+ 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, axis placement controls, themes, Persian/English UI strings, and explode mode.
4
+
5
+ ## Files
6
+
7
+ - `src/index.js` - public package API
8
+ - `src/index.d.ts` - public TypeScript declarations
9
+ - `src/styles.css` - required component styles
10
+ - `src/timeline-view.js` - low-level timeline renderer
11
+ - `src/paststruct.js` - PastStruct normalization helpers
12
+ - `examples/basic.html` - no-build browser example
13
+
14
+ ## Basic Usage
15
+
16
+ ```html
17
+ <link rel="stylesheet" href="/path/to/histui/src/styles.css">
18
+ <div id="timeline" style="height: 720px"></div>
19
+ <script type="module">
20
+ import { createHistuiTimeline } from "/path/to/histui/src/index.js";
21
+
22
+ const histui = createHistuiTimeline({
23
+ container: "#timeline",
24
+ data: pastStructDataset,
25
+ language: "en",
26
+ themeId: "obsidian-lab",
27
+ explodeEnabled: false,
28
+ onSelect(record) {
29
+ console.log("selected", record.id);
30
+ },
31
+ onViewportChange(viewport) {
32
+ console.log(viewport);
33
+ }
34
+ });
35
+
36
+ histui.setExplodeEnabled(true);
37
+ histui.setFilters({ minSignificance: 7 });
38
+ </script>
39
+ ```
40
+
41
+ ## Local Development
42
+
43
+ Run the package directly from this repository when you want to develop `histui` itself and see source changes immediately in the browser:
44
+
45
+ ```bash
46
+ npm run dev
47
+ ```
48
+
49
+ Open [http://127.0.0.1:5175](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.
50
+
51
+ 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.
52
+
53
+ Use a custom port when needed:
54
+
55
+ ```bash
56
+ PORT=5180 npm run dev
57
+ ```
58
+
59
+ Keep this server running while editing package files. For testing the package inside `histui-app-2`, keep the app pointed at the local file dependency (`"histui": "file:../histui"`), run the app dev server in that repo, and reinstall there only after changing package metadata or dependency wiring.
60
+
61
+ ## Public API
62
+
63
+ ```js
64
+ import {
65
+ HistuiTimeline,
66
+ createHistuiTimeline,
67
+ normalizeTimelineData,
68
+ normalizePastStruct,
69
+ createDefaultFilters,
70
+ filterRecords,
71
+ DEFAULT_HISTUI_CONFIG
72
+ } from "histui";
73
+ ```
74
+
75
+ ### `createHistuiTimeline(options)`
76
+
77
+ Creates and mounts a timeline instance.
78
+
79
+ Common options:
80
+
81
+ - `container`: CSS selector or element. Required.
82
+ - `data`: PastStruct dataset document, single PastStruct record, or array of records.
83
+ - `records`: normalized records or raw PastStruct record array.
84
+ - `config`: partial config merged with `DEFAULT_HISTUI_CONFIG`.
85
+ - `language`: default `"en"`.
86
+ - `direction`: optional text direction override.
87
+ - `themeId` or `theme`: built-in theme id or custom theme object.
88
+ - `controls`: render built-in timeline controls. Default `true`.
89
+ - `replace`: clear the container before mounting. Default `true`.
90
+ - `filters`: initial filter object.
91
+ - `orientation`: `"auto"`, `"horizontal"`, or `"vertical"`.
92
+ - `axisPlacement`: `{ horizontal, vertical }`, each `"center"`, `"side-start"`, or `"side-end"`.
93
+ - `lodEnabled`: boolean.
94
+ - `explodeEnabled`: boolean.
95
+ - `analytics.measurementId`: optional Google Analytics measurement id.
96
+ - `onSelect(record, instance)`: event callback.
97
+ - `onViewportChange(viewport, instance)`: event callback.
98
+ - `onRecordsChange(records, instance)`: event callback.
99
+ - `onTrack(name, payload, instance)`: analytics/telemetry callback.
100
+
101
+ ### Instance Methods
102
+
103
+ - `setData(data, options)`
104
+ - `setRecords(records, options)`
105
+ - `setFilters(filters, options)`
106
+ - `resetFilters(options)`
107
+ - `select(recordId, options)`
108
+ - `fit(options)`
109
+ - `zoomBy(factor)`
110
+ - `setViewRange(start, end, options)`
111
+ - `setOrientation(orientation)`
112
+ - `setAxisPlacement(orientation, placement)`
113
+ - `setLodEnabled(enabled)`
114
+ - `setExplodeEnabled(enabled)`
115
+ - `setLanguage(language, direction)`
116
+ - `setTheme(themeOrId)`
117
+ - `getState()`
118
+ - `destroy()`
119
+
120
+ ## Filters
121
+
122
+ `setFilters()` accepts the same filter shape used internally:
123
+
124
+ ```js
125
+ histui.setFilters({
126
+ search: "revolution",
127
+ recordTypes: ["event", "period"],
128
+ types: ["political"],
129
+ minSignificance: 6,
130
+ mediaOnly: false,
131
+ uncertainOnly: false,
132
+ fromYear: 1800,
133
+ toYear: 2026
134
+ });
135
+ ```
136
+
137
+ Set-like fields can be arrays or `Set` instances.
138
+
139
+ ## Config
140
+
141
+ The package exposes `DEFAULT_HISTUI_CONFIG`. You can override only the keys you need:
142
+
143
+ ```js
144
+ createHistuiTimeline({
145
+ container,
146
+ data,
147
+ config: {
148
+ timeline: {
149
+ explode: {
150
+ maxVisible: 42,
151
+ layers: 8,
152
+ animationMs: 700
153
+ }
154
+ }
155
+ }
156
+ });
157
+ ```
158
+
159
+ ## Check
160
+
161
+ ```bash
162
+ npm run check
163
+ ```
164
+
165
+ ## Publishing
166
+
167
+ See [PUBLISHING.md](./PUBLISHING.md) for the npm publishing checklist.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@mim/histui",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.1.0",
7
+ "description": "Reusable Histui interactive timeline package for PastStruct and normalized historical records.",
8
+ "type": "module",
9
+ "main": "./src/index.js",
10
+ "module": "./src/index.js",
11
+ "types": "./src/index.d.ts",
12
+ "style": "./src/styles.css",
13
+ "exports": {
14
+ ".": "./src/index.js",
15
+ "./styles.css": "./src/styles.css"
16
+ },
17
+ "sideEffects": [
18
+ "./src/styles.css"
19
+ ],
20
+ "files": [
21
+ "src",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "dev": "node scripts/dev-server.mjs",
26
+ "check": "node scripts/check.mjs"
27
+ },
28
+ "keywords": [
29
+ "history",
30
+ "timeline",
31
+ "paststruct",
32
+ "visualization"
33
+ ],
34
+ "license": "UNLICENSED"
35
+ }
@@ -0,0 +1,25 @@
1
+ let analyticsReady = false;
2
+
3
+ export function initializeAnalytics({ measurementId, appName = "Histui" } = {}) {
4
+ const id = String(measurementId || "").trim();
5
+ if (!id || analyticsReady || typeof window === "undefined") return;
6
+
7
+ window.dataLayer = window.dataLayer || [];
8
+ window.gtag = function gtag() {
9
+ window.dataLayer.push(arguments);
10
+ };
11
+ window.gtag("js", new Date());
12
+ window.gtag("config", id, { app_name: appName });
13
+
14
+ const script = document.createElement("script");
15
+ script.async = true;
16
+ script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`;
17
+ document.head.append(script);
18
+ analyticsReady = true;
19
+ }
20
+
21
+ export function trackAnalyticsEvent(name, params = {}) {
22
+ if (typeof window === "undefined" || typeof window.gtag !== "function") return;
23
+ window.gtag("event", name, params);
24
+ }
25
+
@@ -0,0 +1,120 @@
1
+ export const DEFAULT_HISTUI_CONFIG = {
2
+ app: {
3
+ name: "Histui",
4
+ defaultLanguage: "en",
5
+ languages: ["en", "fa"],
6
+ defaultTheme: "obsidian-lab",
7
+ orientation: "auto",
8
+ axisPlacement: {
9
+ horizontal: "center",
10
+ vertical: "side-start"
11
+ }
12
+ },
13
+ analytics: {
14
+ googleAnalyticsMeasurementId: ""
15
+ },
16
+ timeline: {
17
+ minZoomSpanYears: 2,
18
+ maxZoomMultiplier: 2.5,
19
+ defaultPaddingRatio: 0.08,
20
+ inertia: {
21
+ enabled: true,
22
+ friction: 0.92,
23
+ wheelFriction: 0.86,
24
+ minVelocity: 0.02
25
+ },
26
+ navigator: {
27
+ enabled: true,
28
+ animationMs: 420,
29
+ trackInsetPx: 18,
30
+ minSelectionPixels: 10
31
+ },
32
+ lod: {
33
+ enabled: true,
34
+ thresholds: [
35
+ { spanYears: 2600, minSignificance: 9, labelMode: "icon" },
36
+ { spanYears: 1100, minSignificance: 8, labelMode: "short" },
37
+ { spanYears: 450, minSignificance: 7, labelMode: "short" },
38
+ { spanYears: 170, minSignificance: 5, labelMode: "standard" },
39
+ { spanYears: 0, minSignificance: 1, labelMode: "full" }
40
+ ]
41
+ },
42
+ explode: {
43
+ enabled: false,
44
+ maxVisible: 34,
45
+ minVisible: 10,
46
+ layers: 6,
47
+ densityPixels: 8800,
48
+ animationMs: 620
49
+ }
50
+ },
51
+ themes: [
52
+ {
53
+ id: "obsidian-lab",
54
+ label: {
55
+ en: "Obsidian Lab",
56
+ fa: "آزمایشگاه آبسیدین"
57
+ },
58
+ colors: {
59
+ background: "#0f1412",
60
+ surface: "#151b18",
61
+ surfaceRaised: "#202821",
62
+ panel: "#111714",
63
+ text: "#edf2ea",
64
+ muted: "#9ba89d",
65
+ line: "#35433b",
66
+ grid: "#26322c",
67
+ accent: "#4fb7a5",
68
+ accent2: "#d4b45f",
69
+ accent3: "#f0705a",
70
+ accent4: "#81c7d4",
71
+ shadow: "rgba(0, 0, 0, 0.42)"
72
+ }
73
+ },
74
+ {
75
+ id: "museum-glass",
76
+ label: {
77
+ en: "Museum Glass",
78
+ fa: "شیشه موزه"
79
+ },
80
+ colors: {
81
+ background: "#f4f2ec",
82
+ surface: "#fffdf8",
83
+ surfaceRaised: "#ffffff",
84
+ panel: "#ebe7dd",
85
+ text: "#242820",
86
+ muted: "#697163",
87
+ line: "#cec7b8",
88
+ grid: "#ded8cb",
89
+ accent: "#257c78",
90
+ accent2: "#a66b2c",
91
+ accent3: "#b84740",
92
+ accent4: "#4a7c9f",
93
+ shadow: "rgba(67, 54, 32, 0.16)"
94
+ }
95
+ },
96
+ {
97
+ id: "graphite-citrus",
98
+ label: {
99
+ en: "Graphite Citrus",
100
+ fa: "گرافیت مرکباتی"
101
+ },
102
+ colors: {
103
+ background: "#191a18",
104
+ surface: "#22231f",
105
+ surfaceRaised: "#2d3029",
106
+ panel: "#141512",
107
+ text: "#f5f7ed",
108
+ muted: "#a9b09d",
109
+ line: "#474b3f",
110
+ grid: "#33382e",
111
+ accent: "#b5d342",
112
+ accent2: "#42b6a3",
113
+ accent3: "#e86f4f",
114
+ accent4: "#f0bd52",
115
+ shadow: "rgba(0, 0, 0, 0.38)"
116
+ }
117
+ }
118
+ ]
119
+ };
120
+
package/src/filters.js ADDED
@@ -0,0 +1,61 @@
1
+ export function getDatasetBounds(records) {
2
+ if (!records.length) {
3
+ const year = new Date().getUTCFullYear();
4
+ return { start: year - 10, end: year + 10 };
5
+ }
6
+ const starts = records.map((record) => record.__meta.start).filter(Number.isFinite);
7
+ const ends = records.map((record) => record.__meta.end).filter(Number.isFinite);
8
+ return {
9
+ start: Math.min(...starts, ...ends),
10
+ end: Math.max(...starts, ...ends)
11
+ };
12
+ }
13
+
14
+ export function createDefaultFilters(records, facets = {}) {
15
+ const bounds = getDatasetBounds(records);
16
+ return {
17
+ search: "",
18
+ recordTypes: new Set((facets.recordTypes || []).map((item) => item.key)),
19
+ types: new Set((facets.types || []).map((item) => item.key)),
20
+ factuality: new Set((facets.factuality || []).map((item) => item.key)),
21
+ confidence: new Set((facets.confidence || []).map((item) => item.key)),
22
+ scopes: new Set((facets.scopes || []).map((item) => item.key)),
23
+ categories: new Set((facets.categories || []).map((item) => item.key)),
24
+ countries: new Set((facets.countries || []).map((item) => item.key)),
25
+ minSignificance: 1,
26
+ mediaOnly: false,
27
+ uncertainOnly: false,
28
+ fromYear: Math.floor(bounds.start),
29
+ toYear: Math.ceil(bounds.end)
30
+ };
31
+ }
32
+
33
+ export function normalizeFilters(filters = {}, baseFilters = {}) {
34
+ const merged = { ...baseFilters, ...filters };
35
+ for (const key of ["recordTypes", "types", "factuality", "confidence", "scopes", "categories", "countries"]) {
36
+ if (Array.isArray(merged[key])) merged[key] = new Set(merged[key]);
37
+ else if (!(merged[key] instanceof Set)) merged[key] = new Set(merged[key] ? [merged[key]] : []);
38
+ }
39
+ return merged;
40
+ }
41
+
42
+ export function filterRecords(records, filters) {
43
+ if (!filters) return records;
44
+ const query = String(filters.search || "").trim().toLocaleLowerCase();
45
+ return records.filter((record) => {
46
+ if (query && !record.__meta.searchText.includes(query)) return false;
47
+ if (filters.recordTypes instanceof Set && !filters.recordTypes.has(record.recordType)) return false;
48
+ if (record.type && filters.types instanceof Set && !filters.types.has(record.type)) return false;
49
+ if (filters.factuality instanceof Set && !filters.factuality.has(record.factuality || "unknown")) return false;
50
+ if (filters.confidence instanceof Set && !filters.confidence.has(record.__meta.confidence || "unknown")) return false;
51
+ if (filters.scopes instanceof Set && !filters.scopes.has(record.__meta.scope || "local")) return false;
52
+ if (record.__meta.categories.length && filters.categories instanceof Set && !record.__meta.categories.some((category) => filters.categories.has(category))) return false;
53
+ if (record.__meta.countries.length && filters.countries instanceof Set && !record.__meta.countries.some((country) => filters.countries.has(country))) return false;
54
+ if (record.__meta.importance < Number(filters.minSignificance || 1)) return false;
55
+ if (filters.mediaOnly && !record.__meta.hasMedia) return false;
56
+ if (filters.uncertainOnly && !record.__meta.temporalUncertainty) return false;
57
+ if (Number.isFinite(filters.fromYear) && record.__meta.end < filters.fromYear) return false;
58
+ if (Number.isFinite(filters.toYear) && record.__meta.start > filters.toYear) return false;
59
+ return true;
60
+ });
61
+ }
package/src/i18n.js ADDED
@@ -0,0 +1,184 @@
1
+ export const UI_STRINGS = {
2
+ en: {
3
+ dataset: "Dataset",
4
+ language: "Language",
5
+ theme: "Theme",
6
+ orientation: "Orientation",
7
+ auto: "Auto",
8
+ horizontal: "Horizontal",
9
+ vertical: "Vertical",
10
+ axis: "Axis",
11
+ middle: "Middle",
12
+ sideStart: "Side start",
13
+ sideEnd: "Side end",
14
+ filters: "Filters",
15
+ search: "Search",
16
+ searchPlaceholder: "Search records",
17
+ recordTypes: "Record types",
18
+ specificTypes: "Specific types",
19
+ factuality: "Factuality",
20
+ confidence: "Confidence",
21
+ scope: "Scope",
22
+ categories: "Categories",
23
+ countries: "Countries",
24
+ dateRange: "Date range",
25
+ from: "From",
26
+ to: "To",
27
+ significance: "Significance",
28
+ mediaOnly: "Media only",
29
+ uncertainOnly: "Uncertain dates",
30
+ lod: "Adaptive detail",
31
+ explode: "Explode",
32
+ resetFilters: "Reset",
33
+ zoomIn: "Zoom in",
34
+ zoomOut: "Zoom out",
35
+ fit: "Fit",
36
+ resetView: "Reset view",
37
+ selected: "Selected",
38
+ noSelection: "Select a record",
39
+ records: "records",
40
+ visible: "visible",
41
+ hidden: "reduced",
42
+ span: "span",
43
+ details: "Details",
44
+ overview: "Overview",
45
+ dates: "Dates",
46
+ entities: "Entities",
47
+ places: "Places",
48
+ relationships: "Relationships",
49
+ interpretations: "Interpretations",
50
+ sources: "Sources",
51
+ funFacts: "Fun facts",
52
+ media: "Media",
53
+ extensions: "Extensions",
54
+ notes: "Notes",
55
+ type: "Type",
56
+ category: "Category",
57
+ rank: "Rank",
58
+ method: "Method",
59
+ basis: "Basis",
60
+ reliability: "Reliability",
61
+ noRecords: "No records match the filters.",
62
+ loading: "Loading",
63
+ all: "All",
64
+ clear: "Clear",
65
+ apply: "Apply",
66
+ ongoing: "present",
67
+ circa: "c.",
68
+ bce: "BCE",
69
+ ce: "CE",
70
+ lessDetail: "compact",
71
+ fullDetail: "full",
72
+ mediaOpen: "Open media",
73
+ sourceOpen: "Open source",
74
+ hiddenEvents: "hidden events",
75
+ clusterHint: "Click to expand temporarily",
76
+ clusterExpanded: "{count} hidden events expanded",
77
+ timelineOverview: "Timeline overview",
78
+ zoomWindow: "Current viewing window",
79
+ zoomSelection: "Selected zoom range",
80
+ currentView: "{count} of {total} records",
81
+ zoomLevel: "{span} years",
82
+ attestation: "Attestation",
83
+ originalDate: "Original",
84
+ statusReady: "{visible} visible, {hidden} reduced by detail level"
85
+ },
86
+ fa: {
87
+ dataset: "مجموعه",
88
+ language: "زبان",
89
+ theme: "پوسته",
90
+ orientation: "جهت",
91
+ auto: "خودکار",
92
+ horizontal: "افقی",
93
+ vertical: "عمودی",
94
+ axis: "محور",
95
+ middle: "میانه",
96
+ sideStart: "کنار آغاز",
97
+ sideEnd: "کنار پایان",
98
+ filters: "فیلترها",
99
+ search: "جستجو",
100
+ searchPlaceholder: "جستجوی رخدادها",
101
+ recordTypes: "گونه رکورد",
102
+ specificTypes: "نوع های دقیق",
103
+ factuality: "اعتبار تاریخی",
104
+ confidence: "اطمینان",
105
+ scope: "گستره",
106
+ categories: "دسته ها",
107
+ countries: "کشورها",
108
+ dateRange: "بازه زمانی",
109
+ from: "از",
110
+ to: "تا",
111
+ significance: "اهمیت",
112
+ mediaOnly: "فقط رسانه",
113
+ uncertainOnly: "تاریخ نامطمئن",
114
+ lod: "جزئیات تطبیقی",
115
+ explode: "نمای انفجاری",
116
+ resetFilters: "بازنشانی",
117
+ zoomIn: "بزرگ نمایی",
118
+ zoomOut: "کوچک نمایی",
119
+ fit: "نمای کامل",
120
+ resetView: "بازنشانی نما",
121
+ selected: "انتخاب شده",
122
+ noSelection: "یک رکورد را انتخاب کنید",
123
+ records: "رکورد",
124
+ visible: "نمایان",
125
+ hidden: "کاهش یافته",
126
+ span: "بازه",
127
+ details: "جزئیات",
128
+ overview: "نمای کلی",
129
+ dates: "تاریخ ها",
130
+ entities: "کنشگران",
131
+ places: "مکان ها",
132
+ relationships: "روابط",
133
+ interpretations: "تفسیرها",
134
+ sources: "منابع",
135
+ funFacts: "نکته ها",
136
+ media: "رسانه",
137
+ extensions: "افزونه ها",
138
+ notes: "یادداشت",
139
+ type: "نوع",
140
+ category: "دسته",
141
+ rank: "رتبه",
142
+ method: "روش",
143
+ basis: "پشتوانه",
144
+ reliability: "اعتبار",
145
+ noRecords: "هیچ رکوردی با فیلترها سازگار نیست.",
146
+ loading: "در حال بارگذاری",
147
+ all: "همه",
148
+ clear: "پاک کردن",
149
+ apply: "اعمال",
150
+ ongoing: "اکنون",
151
+ circa: "حدود",
152
+ bce: "پیش از میلاد",
153
+ ce: "میلادی",
154
+ lessDetail: "فشرده",
155
+ fullDetail: "کامل",
156
+ mediaOpen: "باز کردن رسانه",
157
+ sourceOpen: "باز کردن منبع",
158
+ hiddenEvents: "رخداد پنهان",
159
+ clusterHint: "برای باز شدن موقت کلیک کنید",
160
+ clusterExpanded: "{count} رخداد پنهان باز شده",
161
+ timelineOverview: "نمای کلی زمان",
162
+ zoomWindow: "پنجره نمای کنونی",
163
+ zoomSelection: "بازه بزرگ نمایی انتخاب شده",
164
+ currentView: "{count} از {total} رکورد",
165
+ zoomLevel: "{span} سال",
166
+ attestation: "گواهی تاریخی",
167
+ originalDate: "اصل تاریخ",
168
+ statusReady: "{visible} نمایان، {hidden} با سطح جزئیات کاهش یافته"
169
+ }
170
+ };
171
+
172
+ export function dirForLanguage(language) {
173
+ return /^(fa|ar|he|ur)(-|$)/i.test(language) ? "rtl" : "ltr";
174
+ }
175
+
176
+ export function makeTranslator(language) {
177
+ const strings = UI_STRINGS[language] || UI_STRINGS.en;
178
+ return function translate(key, values = {}) {
179
+ const template = strings[key] || UI_STRINGS.en[key] || key;
180
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => {
181
+ return Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : "";
182
+ });
183
+ };
184
+ }