@ninthforge/timeline 0.0.0-stage → 1.0.0-rc.1
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/LICENSE.md +9 -0
- package/README.md +81 -3
- package/dist/index.cjs +2451 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +416 -0
- package/dist/index.d.ts +416 -0
- package/dist/index.js +2404 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,2404 @@
|
|
|
1
|
+
// src/Timeline.tsx
|
|
2
|
+
import * as React5 from "react";
|
|
3
|
+
|
|
4
|
+
// src/version.ts
|
|
5
|
+
var VERSION = "1.0.0-rc.1";
|
|
6
|
+
|
|
7
|
+
// src/Timeline.tsx
|
|
8
|
+
import { useEffect as useEffect3, useLayoutEffect, useMemo as useMemo2, useRef as useRef3, useState as useState4 } from "react";
|
|
9
|
+
|
|
10
|
+
// ../core/dist/chunk-ZAYD57MJ.js
|
|
11
|
+
var MAX = 80;
|
|
12
|
+
function clean(value) {
|
|
13
|
+
const s = String(value ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
|
14
|
+
return s.length > MAX ? `${s.slice(0, MAX - 1)}\u2026` : s;
|
|
15
|
+
}
|
|
16
|
+
function envOf(win) {
|
|
17
|
+
if (win) return win;
|
|
18
|
+
return typeof window === "undefined" ? {} : window;
|
|
19
|
+
}
|
|
20
|
+
function ancestors(env) {
|
|
21
|
+
try {
|
|
22
|
+
const list = env.location?.ancestorOrigins;
|
|
23
|
+
return list ? Array.from(list, String) : [];
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function hostnameOf(origin) {
|
|
29
|
+
return origin.replace(/^[a-z]+:\/\//i, "").replace(/[:/].*$/, "").toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
var endsWith = (host, suffix) => host === suffix || host.endsWith(`.${suffix}`);
|
|
32
|
+
function detectHost(win) {
|
|
33
|
+
const env = envOf(win);
|
|
34
|
+
const xrm = env.Xrm;
|
|
35
|
+
if (xrm) {
|
|
36
|
+
try {
|
|
37
|
+
if (xrm.Utility?.getPageContext?.()?.input?.pageType === "custom") return "custom page";
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
return "model-driven";
|
|
41
|
+
}
|
|
42
|
+
const up = ancestors(env).map(hostnameOf);
|
|
43
|
+
if (up.some((h) => endsWith(h, "powerbi.com") || endsWith(h, "fabric.microsoft.com"))) return "Power BI";
|
|
44
|
+
const host = (env.location?.hostname ?? "").toLowerCase();
|
|
45
|
+
if (endsWith(host, "powerbi.com")) return "Power BI";
|
|
46
|
+
if (up.some((h) => endsWith(h, "dynamics.com"))) return "custom page";
|
|
47
|
+
if (host.startsWith("authoring.") && endsWith(host, "powerapps.com")) return "canvas (Studio)";
|
|
48
|
+
if (endsWith(host, "powerapps.com") || endsWith(host, "powerplatformusercontent.com")) return "canvas";
|
|
49
|
+
return "web page";
|
|
50
|
+
}
|
|
51
|
+
var BRANDS = [
|
|
52
|
+
[/Edg(?:e|A|iOS)?\/(\d+)/, "Edge"],
|
|
53
|
+
[/OPR\/(\d+)/, "Opera"],
|
|
54
|
+
[/Firefox\/(\d+)/, "Firefox"],
|
|
55
|
+
[/FxiOS\/(\d+)/, "Firefox"],
|
|
56
|
+
[/CriOS\/(\d+)/, "Chrome"],
|
|
57
|
+
[/Chrome\/(\d+)/, "Chrome"],
|
|
58
|
+
[/Version\/(\d+)[.\d]* (?:Mobile\/\S+ )?Safari\//, "Safari"]
|
|
59
|
+
];
|
|
60
|
+
var PLATFORMS = [
|
|
61
|
+
[/iPhone|iPad|iPod/, "iOS"],
|
|
62
|
+
[/Android/, "Android"],
|
|
63
|
+
[/Windows/, "Windows"],
|
|
64
|
+
[/Mac OS X|Macintosh/, "macOS"],
|
|
65
|
+
[/CrOS/, "ChromeOS"],
|
|
66
|
+
[/Linux/, "Linux"]
|
|
67
|
+
];
|
|
68
|
+
function describeBrowser(win) {
|
|
69
|
+
const nav = envOf(win).navigator;
|
|
70
|
+
const ua = nav?.userAgent ?? "";
|
|
71
|
+
let name = "";
|
|
72
|
+
for (const [re, brand2] of BRANDS) {
|
|
73
|
+
const m = re.exec(ua);
|
|
74
|
+
if (m) {
|
|
75
|
+
name = `${brand2} ${m[1]}`;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!name) {
|
|
80
|
+
const brands = nav?.userAgentData?.brands ?? [];
|
|
81
|
+
const b = brands.find((x) => !/not.?a.?brand|chromium/i.test(x.brand)) ?? brands[0];
|
|
82
|
+
name = b ? `${b.brand.replace("Microsoft ", "")} ${b.version}` : "Unknown browser";
|
|
83
|
+
}
|
|
84
|
+
const platform = PLATFORMS.find(([re]) => re.test(ua))?.[1] ?? nav?.userAgentData?.platform;
|
|
85
|
+
return clean(platform ? `${name} on ${platform}` : name);
|
|
86
|
+
}
|
|
87
|
+
function offsetLabel(minutes) {
|
|
88
|
+
const sign = minutes < 0 ? "\u2212" : "+";
|
|
89
|
+
const abs = Math.abs(minutes);
|
|
90
|
+
return `UTC${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
|
|
91
|
+
}
|
|
92
|
+
function licenceOf(l) {
|
|
93
|
+
if (!l) return { state: "not checked" };
|
|
94
|
+
const p = l.payload ?? void 0;
|
|
95
|
+
let state = clean(l.status) || "unknown";
|
|
96
|
+
if ((state === "valid" || state === "unverifiable") && p?.tier === "trial") state = "trial";
|
|
97
|
+
else if (state === "unverifiable") state = "valid (browser cannot verify)";
|
|
98
|
+
const out = { state };
|
|
99
|
+
if (p?.org) out.org = clean(p.org);
|
|
100
|
+
if (p?.exp && /^\d{4}-\d{2}-\d{2}$/.test(p.exp)) out.expires = p.exp;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function propertiesOf(p) {
|
|
104
|
+
if (!p) return [];
|
|
105
|
+
if (Array.isArray(p)) return p.map(clean).filter(Boolean);
|
|
106
|
+
return Object.keys(p).map((name) => {
|
|
107
|
+
const column = p[name];
|
|
108
|
+
return column ? `${clean(name)} \u2192 ${clean(column)}` : `${clean(name)} (not mapped)`;
|
|
109
|
+
}).filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
function collectDiagnostics(input, win) {
|
|
112
|
+
const env = envOf(win);
|
|
113
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
114
|
+
let locale = clean(env.navigator?.language);
|
|
115
|
+
let zone = "";
|
|
116
|
+
try {
|
|
117
|
+
const opts = Intl.DateTimeFormat().resolvedOptions();
|
|
118
|
+
if (!locale) locale = opts.locale;
|
|
119
|
+
else if (opts.locale && opts.locale !== locale) locale = `${locale} (formats ${opts.locale})`;
|
|
120
|
+
zone = opts.timeZone ?? "";
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
const offset = offsetLabel(-now.getTimezoneOffset());
|
|
124
|
+
const extra = Object.keys(input.extra ?? {}).filter((k) => input.extra[k] !== void 0 && input.extra[k] !== null).map((k) => [clean(k), clean(input.extra[k])]);
|
|
125
|
+
const d = {
|
|
126
|
+
product: clean(input.product),
|
|
127
|
+
control: clean(input.control),
|
|
128
|
+
version: clean(input.version),
|
|
129
|
+
licence: licenceOf(input.licence),
|
|
130
|
+
host: input.host ?? detectHost(env),
|
|
131
|
+
browser: describeBrowser(env),
|
|
132
|
+
locale: locale || "unknown",
|
|
133
|
+
timeZone: zone ? `${clean(zone)} (${offset})` : offset,
|
|
134
|
+
properties: propertiesOf(input.properties),
|
|
135
|
+
extra,
|
|
136
|
+
at: now.toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
137
|
+
};
|
|
138
|
+
if (typeof input.rows === "number" && Number.isFinite(input.rows)) d.rows = Math.max(0, Math.round(input.rows));
|
|
139
|
+
return d;
|
|
140
|
+
}
|
|
141
|
+
function formatDiagnostics(d) {
|
|
142
|
+
const lic = [d.licence.state, d.licence.org, d.licence.expires && `expires ${d.licence.expires}`].filter(Boolean).join(" \xB7 ");
|
|
143
|
+
const lines = [
|
|
144
|
+
"Ninth Forge diagnostics",
|
|
145
|
+
`Product: ${d.product} \xB7 ${d.control} ${d.version}`,
|
|
146
|
+
`Licence: ${lic}`,
|
|
147
|
+
`Host: ${d.host}`,
|
|
148
|
+
`Browser: ${d.browser}`,
|
|
149
|
+
`Locale: ${d.locale}`,
|
|
150
|
+
`Time zone: ${d.timeZone}`,
|
|
151
|
+
`Properties: ${d.properties.length ? d.properties.join(", ") : "none mapped"}`
|
|
152
|
+
];
|
|
153
|
+
if (d.rows !== void 0) lines.push(`Rows: ${d.rows}`);
|
|
154
|
+
for (const [k, v] of d.extra) lines.push(`${k}: ${v}`);
|
|
155
|
+
lines.push(`Copied: ${d.at}`);
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|
|
158
|
+
async function copyText(text, win) {
|
|
159
|
+
const env = win ?? (typeof window === "undefined" ? {} : window);
|
|
160
|
+
const fallback2 = () => {
|
|
161
|
+
const doc = env.document;
|
|
162
|
+
if (!doc?.body) return false;
|
|
163
|
+
const active = doc.activeElement;
|
|
164
|
+
const area = doc.createElement("textarea");
|
|
165
|
+
area.value = text;
|
|
166
|
+
area.setAttribute("readonly", "");
|
|
167
|
+
area.setAttribute("aria-hidden", "true");
|
|
168
|
+
area.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none";
|
|
169
|
+
doc.body.appendChild(area);
|
|
170
|
+
let ok = false;
|
|
171
|
+
try {
|
|
172
|
+
area.select();
|
|
173
|
+
ok = doc.execCommand("copy");
|
|
174
|
+
} catch {
|
|
175
|
+
ok = false;
|
|
176
|
+
}
|
|
177
|
+
area.remove();
|
|
178
|
+
active?.focus?.();
|
|
179
|
+
return ok;
|
|
180
|
+
};
|
|
181
|
+
const write = env.navigator?.clipboard?.writeText;
|
|
182
|
+
if (write) {
|
|
183
|
+
try {
|
|
184
|
+
await write.call(env.navigator.clipboard, text);
|
|
185
|
+
return true;
|
|
186
|
+
} catch {
|
|
187
|
+
return fallback2();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return fallback2();
|
|
191
|
+
}
|
|
192
|
+
var DIAGNOSTICS_COPIED = "Diagnostics copied. Paste them into your e-mail to support.";
|
|
193
|
+
var DIAGNOSTICS_NOT_COPIED = "Couldn't copy diagnostics. Your browser blocked the clipboard.";
|
|
194
|
+
async function copyDiagnostics(input, win) {
|
|
195
|
+
const text = formatDiagnostics(collectDiagnostics(input, win));
|
|
196
|
+
const ok = await copyText(text, win);
|
|
197
|
+
return { ok, text, message: ok ? DIAGNOSTICS_COPIED : DIAGNOSTICS_NOT_COPIED };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ../core/dist/index.js
|
|
201
|
+
var DAY_MS = 864e5;
|
|
202
|
+
var ISO = /^(\d{4})-(\d{2})-(\d{2})/;
|
|
203
|
+
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
204
|
+
function toDay(iso) {
|
|
205
|
+
if (!iso) return NaN;
|
|
206
|
+
const m = ISO.exec(iso);
|
|
207
|
+
if (!m) return NaN;
|
|
208
|
+
const y = Number(m[1]);
|
|
209
|
+
const mo = Number(m[2]);
|
|
210
|
+
const d = Number(m[3]);
|
|
211
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31) return NaN;
|
|
212
|
+
return Math.round(Date.UTC(y, mo - 1, d) / DAY_MS);
|
|
213
|
+
}
|
|
214
|
+
function parts(day) {
|
|
215
|
+
const d = new Date(day * DAY_MS);
|
|
216
|
+
return { year: d.getUTCFullYear(), month: d.getUTCMonth(), date: d.getUTCDate() };
|
|
217
|
+
}
|
|
218
|
+
function dayOf(year, month, date = 1) {
|
|
219
|
+
return Math.round(Date.UTC(year, month, date) / DAY_MS);
|
|
220
|
+
}
|
|
221
|
+
function addMonths(day, n) {
|
|
222
|
+
const p = parts(day);
|
|
223
|
+
return dayOf(p.year, p.month + n, 1);
|
|
224
|
+
}
|
|
225
|
+
function today(now = /* @__PURE__ */ new Date()) {
|
|
226
|
+
return dayOf(now.getFullYear(), now.getMonth(), now.getDate());
|
|
227
|
+
}
|
|
228
|
+
function escapeHtml(s) {
|
|
229
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
230
|
+
}
|
|
231
|
+
function exportFileName(title, isoDay, ext = "png", fallback2 = "plan") {
|
|
232
|
+
const slug2 = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback2;
|
|
233
|
+
return `${slug2}-${isoDay}.${ext}`;
|
|
234
|
+
}
|
|
235
|
+
var MAX_CANVAS_SIDE = 16e3;
|
|
236
|
+
var MAX_CANVAS_AREA = 12e7;
|
|
237
|
+
function exportRatio(width, height, preferred = 2) {
|
|
238
|
+
const ratio2 = Math.min(preferred, MAX_CANVAS_SIDE / width, MAX_CANVAS_SIDE / height, Math.sqrt(MAX_CANVAS_AREA / (width * height)));
|
|
239
|
+
return ratio2 >= 0.5 ? ratio2 : null;
|
|
240
|
+
}
|
|
241
|
+
function canvasColor(ctx, color, fallback2) {
|
|
242
|
+
ctx.fillStyle = fallback2;
|
|
243
|
+
if (color) ctx.fillStyle = color;
|
|
244
|
+
return String(ctx.fillStyle);
|
|
245
|
+
}
|
|
246
|
+
function downloadCanvas(canvas, fileName, doc = document) {
|
|
247
|
+
canvas.toBlob((blob) => {
|
|
248
|
+
if (!blob) return;
|
|
249
|
+
const url = URL.createObjectURL(blob);
|
|
250
|
+
const a = doc.createElement("a");
|
|
251
|
+
a.href = url;
|
|
252
|
+
a.download = fileName;
|
|
253
|
+
a.style.display = "none";
|
|
254
|
+
doc.body.appendChild(a);
|
|
255
|
+
a.click();
|
|
256
|
+
a.remove();
|
|
257
|
+
setTimeout(() => URL.revokeObjectURL(url), 1e4);
|
|
258
|
+
}, "image/png");
|
|
259
|
+
}
|
|
260
|
+
function printDocument(html, doc = document) {
|
|
261
|
+
const frame = doc.createElement("iframe");
|
|
262
|
+
frame.setAttribute("aria-hidden", "true");
|
|
263
|
+
frame.setAttribute("tabindex", "-1");
|
|
264
|
+
frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden";
|
|
265
|
+
doc.body.appendChild(frame);
|
|
266
|
+
const win = frame.contentWindow;
|
|
267
|
+
const fdoc = frame.contentDocument;
|
|
268
|
+
if (!win || !fdoc) {
|
|
269
|
+
frame.remove();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
fdoc.open();
|
|
273
|
+
fdoc.write(html);
|
|
274
|
+
fdoc.close();
|
|
275
|
+
let done = false;
|
|
276
|
+
const cleanup = () => {
|
|
277
|
+
if (done) return;
|
|
278
|
+
done = true;
|
|
279
|
+
frame.remove();
|
|
280
|
+
};
|
|
281
|
+
win.addEventListener("afterprint", () => setTimeout(cleanup, 0));
|
|
282
|
+
setTimeout(cleanup, 6e4);
|
|
283
|
+
setTimeout(() => {
|
|
284
|
+
win.focus();
|
|
285
|
+
win.print();
|
|
286
|
+
}, 50);
|
|
287
|
+
}
|
|
288
|
+
var ROADMAP_DAY_WIDTH = { month: 4, quarter: 1.5, half: 0.8 };
|
|
289
|
+
var clampMonth = (m) => Number.isInteger(m) && m >= 1 && m <= 12 ? m : 1;
|
|
290
|
+
var fyMonth = (month, fyStart) => ((month - (fyStart - 1)) % 12 + 12) % 12;
|
|
291
|
+
function fiscalYear(day, fiscalYearStart = 1) {
|
|
292
|
+
const fs = clampMonth(fiscalYearStart);
|
|
293
|
+
const p = parts(day);
|
|
294
|
+
return fs === 1 || p.month < fs - 1 ? p.year : p.year + 1;
|
|
295
|
+
}
|
|
296
|
+
function periodStart(day, months, fiscalYearStart = 1) {
|
|
297
|
+
const fs = clampMonth(fiscalYearStart);
|
|
298
|
+
const p = parts(day);
|
|
299
|
+
const into = fyMonth(p.month, fs) % months;
|
|
300
|
+
return dayOf(p.year, p.month - into, 1);
|
|
301
|
+
}
|
|
302
|
+
function addMonthsFrom(day, n) {
|
|
303
|
+
const p = parts(day);
|
|
304
|
+
return dayOf(p.year, p.month + n, 1);
|
|
305
|
+
}
|
|
306
|
+
function periodLabel(day, kind, fiscalYearStart = 1) {
|
|
307
|
+
const fs = clampMonth(fiscalYearStart);
|
|
308
|
+
const fy = fiscalYear(day, fs);
|
|
309
|
+
const year = fs === 1 ? String(fy) : `FY${String(fy % 100).padStart(2, "0")}`;
|
|
310
|
+
if (kind === "year") return year;
|
|
311
|
+
const m = fyMonth(parts(day).month, fs);
|
|
312
|
+
return kind === "quarter" ? `Q${Math.floor(m / 3) + 1} ${year}` : `H${m < 6 ? 1 : 2} ${year}`;
|
|
313
|
+
}
|
|
314
|
+
function createRoadmapScale({ from, to, zoom, fiscalYearStart, minWidth = 0 }) {
|
|
315
|
+
const fs = clampMonth(fiscalYearStart);
|
|
316
|
+
const step = zoom === "month" ? 1 : zoom === "quarter" ? 3 : 6;
|
|
317
|
+
const startDay = periodStart(Math.min(from, to), step, fs);
|
|
318
|
+
let endDay = addMonthsFrom(periodStart(Math.max(from, to), step, fs), step);
|
|
319
|
+
const dayWidth = ROADMAP_DAY_WIDTH[zoom];
|
|
320
|
+
for (let guard = 0; (endDay - startDay) * dayWidth < minWidth && guard < 240; guard++) endDay = addMonthsFrom(endDay, step);
|
|
321
|
+
const x = (day) => Math.round((day - startDay) * dayWidth * 10) / 10;
|
|
322
|
+
const minor = [];
|
|
323
|
+
for (let d = startDay; d < endDay; d = addMonthsFrom(d, step)) {
|
|
324
|
+
const next2 = addMonthsFrom(d, step);
|
|
325
|
+
const label = zoom === "month" ? MONTHS[parts(d).month] ?? "" : periodLabel(d, zoom, fs);
|
|
326
|
+
minor.push({ day: d, x: x(d), width: x(next2) - x(d), label });
|
|
327
|
+
}
|
|
328
|
+
const months = [];
|
|
329
|
+
if (zoom === "quarter") {
|
|
330
|
+
for (let d = startDay; d < endDay; d = addMonthsFrom(d, 1)) {
|
|
331
|
+
const next2 = addMonthsFrom(d, 1);
|
|
332
|
+
months.push({ day: d, x: x(d), width: x(next2) - x(d), label: MONTHS[parts(d).month] ?? "" });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const major = [];
|
|
336
|
+
for (let yStart = yearStart(startDay, fs); yStart < endDay; ) {
|
|
337
|
+
const yEnd = addMonthsFrom(yStart, 12);
|
|
338
|
+
const left = Math.max(yStart, startDay);
|
|
339
|
+
const right = Math.min(yEnd, endDay);
|
|
340
|
+
major.push({ day: left, x: x(left), width: x(right) - x(left), label: periodLabel(yStart, "year", fs) });
|
|
341
|
+
yStart = yEnd;
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
zoom,
|
|
345
|
+
fiscalYearStart: fs,
|
|
346
|
+
startDay,
|
|
347
|
+
endDay,
|
|
348
|
+
dayWidth,
|
|
349
|
+
width: x(endDay),
|
|
350
|
+
x,
|
|
351
|
+
dayAt: (px) => startDay + Math.floor(px / dayWidth),
|
|
352
|
+
major,
|
|
353
|
+
minor,
|
|
354
|
+
months
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function yearStart(day, fiscalYearStart = 1) {
|
|
358
|
+
const fs = clampMonth(fiscalYearStart);
|
|
359
|
+
const p = parts(day);
|
|
360
|
+
const y = p.month >= fs - 1 ? p.year : p.year - 1;
|
|
361
|
+
return dayOf(y, fs - 1, 1);
|
|
362
|
+
}
|
|
363
|
+
function isMultiSelect(e2) {
|
|
364
|
+
return !!(e2.ctrlKey || e2.metaKey);
|
|
365
|
+
}
|
|
366
|
+
var HOST_DIM = 0.3;
|
|
367
|
+
function isHighlighted(id, highlighted) {
|
|
368
|
+
if (!highlighted) return true;
|
|
369
|
+
if (isSet(highlighted)) return highlighted.size === 0 || highlighted.has(id);
|
|
370
|
+
return highlighted.length === 0 || highlighted.indexOf(id) !== -1;
|
|
371
|
+
}
|
|
372
|
+
function isSet(v) {
|
|
373
|
+
return typeof v.has === "function" && !Array.isArray(v);
|
|
374
|
+
}
|
|
375
|
+
function isContextMenuKey(e2) {
|
|
376
|
+
return e2.key === "ContextMenu" || e2.key === "F10" && !!e2.shiftKey;
|
|
377
|
+
}
|
|
378
|
+
var ratio = (drawn, layout) => layout > 0 && drawn > 0 && Number.isFinite(drawn / layout) ? drawn / layout : 1;
|
|
379
|
+
function cssScale(el) {
|
|
380
|
+
if (!el) return { x: 1, y: 1 };
|
|
381
|
+
const r = el.getBoundingClientRect();
|
|
382
|
+
return { x: ratio(r.width, el.offsetWidth), y: ratio(r.height, el.offsetHeight) };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ../editing/dist/chunk-S4GYYU33.js
|
|
386
|
+
var isEmpty = (v) => v === void 0 || v === null || v === "";
|
|
387
|
+
function defaultEqual(_key, a, b) {
|
|
388
|
+
if (isEmpty(a) && isEmpty(b)) return true;
|
|
389
|
+
return a === b;
|
|
390
|
+
}
|
|
391
|
+
function fieldsOf(change, spec) {
|
|
392
|
+
const out = {};
|
|
393
|
+
for (const k of spec.keys) if (k in change) out[k] = change[k];
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
function applyOverrides(items, overrides) {
|
|
397
|
+
if (overrides.size === 0) return items;
|
|
398
|
+
return items.map((t) => {
|
|
399
|
+
const o = overrides.get(t.id);
|
|
400
|
+
return o ? { ...t, ...o.patch } : t;
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function reconcileOverrides(items, overrides, spec = {}) {
|
|
404
|
+
const eq = spec.equal ?? defaultEqual;
|
|
405
|
+
const byId = new Map(items.map((t) => [t.id, t]));
|
|
406
|
+
const next2 = new Map(overrides);
|
|
407
|
+
for (const [id, o] of overrides) {
|
|
408
|
+
const t = byId.get(id);
|
|
409
|
+
if (!t) {
|
|
410
|
+
next2.delete(id);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const matches2 = Object.keys(o.patch).every(
|
|
414
|
+
(k) => eq(k, t[k], o.patch[k])
|
|
415
|
+
);
|
|
416
|
+
if (matches2) next2.delete(id);
|
|
417
|
+
}
|
|
418
|
+
return next2;
|
|
419
|
+
}
|
|
420
|
+
var HISTORY_LIMIT = 20;
|
|
421
|
+
function emptyHistory() {
|
|
422
|
+
return { undo: [], redo: [] };
|
|
423
|
+
}
|
|
424
|
+
function pushHistory(h, change) {
|
|
425
|
+
return { undo: [...h.undo, change].slice(-HISTORY_LIMIT), redo: [] };
|
|
426
|
+
}
|
|
427
|
+
function dropFromHistory(h, change) {
|
|
428
|
+
return { undo: h.undo.filter((c) => c !== change), redo: h.redo.filter((c) => c !== change) };
|
|
429
|
+
}
|
|
430
|
+
function invertChange(change, source, spec) {
|
|
431
|
+
const forward = fieldsOf(change, spec);
|
|
432
|
+
const back = {};
|
|
433
|
+
for (const k of Object.keys(forward)) {
|
|
434
|
+
const v = change.previous[k];
|
|
435
|
+
back[k] = v !== void 0 ? v : spec.blank ? spec.blank(k) : void 0;
|
|
436
|
+
}
|
|
437
|
+
return { id: change.id, ...back, previous: forward, source };
|
|
438
|
+
}
|
|
439
|
+
function undoStep(h, undoSource, spec) {
|
|
440
|
+
const entry = h.undo[h.undo.length - 1];
|
|
441
|
+
if (!entry) return null;
|
|
442
|
+
return { commit: invertChange(entry, undoSource, spec), entry, history: { undo: h.undo.slice(0, -1), redo: [...h.redo, entry] } };
|
|
443
|
+
}
|
|
444
|
+
function redoStep(h, redoSource) {
|
|
445
|
+
const entry = h.redo[h.redo.length - 1];
|
|
446
|
+
if (!entry) return null;
|
|
447
|
+
const commit = { ...entry, source: redoSource };
|
|
448
|
+
return { commit, entry, history: { undo: [...h.undo, entry].slice(-HISTORY_LIMIT), redo: h.redo.slice(0, -1) } };
|
|
449
|
+
}
|
|
450
|
+
function errorText(err, fallback2 = "The change was not saved.") {
|
|
451
|
+
if (err instanceof Error && err.message) return err.message;
|
|
452
|
+
if (typeof err === "string" && err) return err;
|
|
453
|
+
const m = err?.message;
|
|
454
|
+
return typeof m === "string" && m ? m : fallback2;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ../editing/dist/index.js
|
|
458
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
459
|
+
import * as React from "react";
|
|
460
|
+
import * as React2 from "react";
|
|
461
|
+
import * as React3 from "react";
|
|
462
|
+
import * as React4 from "react";
|
|
463
|
+
var INFO_MS = 5e3;
|
|
464
|
+
var ERROR_MS = 9e3;
|
|
465
|
+
function trialUrl(ref) {
|
|
466
|
+
return `https://ninthforge.com/trial?ref=${encodeURIComponent(ref)}`;
|
|
467
|
+
}
|
|
468
|
+
function lockedMessage(product) {
|
|
469
|
+
return `Saving changes needs a Ninth Forge ${product} key. Start a free 30-day trial at ninthforge.com/trial.`;
|
|
470
|
+
}
|
|
471
|
+
function lockFor(license, unlocked, product, trialRef) {
|
|
472
|
+
if (unlocked) return { locked: false };
|
|
473
|
+
if (!license) return { locked: true };
|
|
474
|
+
return trialRef ? { locked: true, lockedMessage: lockedMessage(product), lockedTrialUrl: trialUrl(trialRef) } : { locked: true, lockedMessage: lockedMessage(product) };
|
|
475
|
+
}
|
|
476
|
+
var toastedLocks = /* @__PURE__ */ new Set();
|
|
477
|
+
function firstLockToast(message) {
|
|
478
|
+
if (toastedLocks.has(message)) return false;
|
|
479
|
+
toastedLocks.add(message);
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
function openTrial(url) {
|
|
483
|
+
if (typeof window !== "undefined") window.open(url, "_blank", "noopener");
|
|
484
|
+
}
|
|
485
|
+
function useEditing(items, onChange, spec) {
|
|
486
|
+
const [overrides, setOverrides] = useState(() => /* @__PURE__ */ new Map());
|
|
487
|
+
const historyRef = useRef(emptyHistory());
|
|
488
|
+
const [, setHistoryVersion] = useState(0);
|
|
489
|
+
const setHistory = (fn) => {
|
|
490
|
+
historyRef.current = fn(historyRef.current);
|
|
491
|
+
setHistoryVersion((v) => v + 1);
|
|
492
|
+
};
|
|
493
|
+
const seqRef = useRef(0);
|
|
494
|
+
const [toasts, setToasts] = useState([]);
|
|
495
|
+
const [announcement, setAnnouncement] = useState({ text: "", n: 0 });
|
|
496
|
+
const timers = useRef(/* @__PURE__ */ new Map());
|
|
497
|
+
const itemsRef = useRef(items);
|
|
498
|
+
itemsRef.current = items;
|
|
499
|
+
const handlerRef = useRef(onChange);
|
|
500
|
+
handlerRef.current = onChange;
|
|
501
|
+
const specRef = useRef(spec);
|
|
502
|
+
specRef.current = spec;
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
setOverrides((o) => o.size ? reconcileOverrides(items, o, specRef.current) : o);
|
|
505
|
+
}, [items]);
|
|
506
|
+
useEffect(() => {
|
|
507
|
+
const map = timers.current;
|
|
508
|
+
return () => map.forEach((t) => clearTimeout(t));
|
|
509
|
+
}, []);
|
|
510
|
+
const dismiss = useCallback((id) => {
|
|
511
|
+
const t = timers.current.get(id);
|
|
512
|
+
if (t) clearTimeout(t);
|
|
513
|
+
timers.current.delete(id);
|
|
514
|
+
setToasts((list) => list.filter((x) => x.id !== id));
|
|
515
|
+
}, []);
|
|
516
|
+
const toast = useCallback(
|
|
517
|
+
(item) => {
|
|
518
|
+
const id = ++seqRef.current;
|
|
519
|
+
setToasts((list) => [...list.filter((x) => x.tone === "error"), { ...item, id }].slice(-4));
|
|
520
|
+
timers.current.set(
|
|
521
|
+
id,
|
|
522
|
+
setTimeout(() => dismiss(id), item.tone === "error" ? ERROR_MS : INFO_MS)
|
|
523
|
+
);
|
|
524
|
+
},
|
|
525
|
+
[dismiss]
|
|
526
|
+
);
|
|
527
|
+
const announce = useCallback((text) => setAnnouncement((a) => ({ text, n: a.n + 1 })), []);
|
|
528
|
+
const [lockNote, setLockNote] = useState(null);
|
|
529
|
+
const refuse = useCallback(() => {
|
|
530
|
+
const msg = specRef.current.lockedMessage;
|
|
531
|
+
if (!msg) return;
|
|
532
|
+
announce(msg);
|
|
533
|
+
setLockNote(msg);
|
|
534
|
+
if (!firstLockToast(msg)) return;
|
|
535
|
+
const url = specRef.current.lockedTrialUrl;
|
|
536
|
+
toast({ text: msg, tone: "info", action: url ? { label: specRef.current.lockedTrialLabel ?? "Start a free trial", run: () => openTrial(url) } : void 0 });
|
|
537
|
+
}, [announce, toast]);
|
|
538
|
+
const nameOf = (id) => {
|
|
539
|
+
const item = itemsRef.current.find((t) => t.id === id);
|
|
540
|
+
const s = specRef.current;
|
|
541
|
+
if (!item) return s.noun ?? "item";
|
|
542
|
+
return s.name ? s.name(item) : item.name ?? s.noun ?? "item";
|
|
543
|
+
};
|
|
544
|
+
const undoRef = useRef(() => void 0);
|
|
545
|
+
const redoRef = useRef(() => void 0);
|
|
546
|
+
const commit = (change, mode, entry) => {
|
|
547
|
+
if (specRef.current.locked) return refuse();
|
|
548
|
+
const seq = ++seqRef.current;
|
|
549
|
+
setOverrides((m) => {
|
|
550
|
+
const next2 = new Map(m);
|
|
551
|
+
next2.set(change.id, { patch: { ...m.get(change.id)?.patch, ...fieldsOf(change, specRef.current) }, seq });
|
|
552
|
+
return next2;
|
|
553
|
+
});
|
|
554
|
+
if (mode === "new") setHistory((h) => pushHistory(h, change));
|
|
555
|
+
const text = specRef.current.describe(change, nameOf);
|
|
556
|
+
announce(text);
|
|
557
|
+
toast({
|
|
558
|
+
text,
|
|
559
|
+
tone: "info",
|
|
560
|
+
action: mode === "undo" ? { label: "Redo", run: () => redoRef.current() } : { label: "Undo", run: () => undoRef.current() }
|
|
561
|
+
});
|
|
562
|
+
let result;
|
|
563
|
+
try {
|
|
564
|
+
result = handlerRef.current?.(change);
|
|
565
|
+
} catch (err) {
|
|
566
|
+
result = Promise.reject(err);
|
|
567
|
+
}
|
|
568
|
+
if (result && typeof result.then === "function") {
|
|
569
|
+
result.then(void 0, (err) => {
|
|
570
|
+
setOverrides((m) => {
|
|
571
|
+
const o = m.get(change.id);
|
|
572
|
+
if (!o || o.seq !== seq) return m;
|
|
573
|
+
const next2 = new Map(m);
|
|
574
|
+
next2.delete(change.id);
|
|
575
|
+
return next2;
|
|
576
|
+
});
|
|
577
|
+
setHistory((h) => {
|
|
578
|
+
if (mode === "new") return dropFromHistory(h, change);
|
|
579
|
+
if (!entry) return h;
|
|
580
|
+
return mode === "undo" ? { undo: [...h.undo, entry], redo: h.redo.filter((c) => c !== entry) } : { undo: h.undo.filter((c) => c !== entry), redo: [...h.redo, entry] };
|
|
581
|
+
});
|
|
582
|
+
const msg = errorText(err);
|
|
583
|
+
toast({ text: `Couldn't save '${nameOf(change.id)}', so it was put back. ${msg}`, tone: "error" });
|
|
584
|
+
announce(`Not saved, put back. ${msg}`);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
const undo = () => {
|
|
589
|
+
const step = undoStep(historyRef.current, "undo", specRef.current);
|
|
590
|
+
if (!step) return announce("Nothing to undo");
|
|
591
|
+
setHistory(() => step.history);
|
|
592
|
+
commit(step.commit, "undo", step.entry);
|
|
593
|
+
};
|
|
594
|
+
const redo = () => {
|
|
595
|
+
const step = redoStep(historyRef.current, "redo");
|
|
596
|
+
if (!step) return announce("Nothing to redo");
|
|
597
|
+
setHistory(() => step.history);
|
|
598
|
+
commit(step.commit, "redo", step.entry);
|
|
599
|
+
};
|
|
600
|
+
undoRef.current = undo;
|
|
601
|
+
redoRef.current = redo;
|
|
602
|
+
const shown = useMemo(() => applyOverrides(items, overrides), [items, overrides]);
|
|
603
|
+
return {
|
|
604
|
+
overrides,
|
|
605
|
+
shown,
|
|
606
|
+
commit: (change) => commit(change, "new"),
|
|
607
|
+
undo,
|
|
608
|
+
redo,
|
|
609
|
+
canUndo: historyRef.current.undo.length > 0,
|
|
610
|
+
canRedo: historyRef.current.redo.length > 0,
|
|
611
|
+
toasts,
|
|
612
|
+
toast,
|
|
613
|
+
dismiss,
|
|
614
|
+
announcement,
|
|
615
|
+
announce,
|
|
616
|
+
/** View-only: Pro actions are refused (see `EditingSpec.locked`). */
|
|
617
|
+
locked: !!spec.locked,
|
|
618
|
+
/** Call instead of an action that bypasses `commit` (create, add) while `locked`. */
|
|
619
|
+
refuse,
|
|
620
|
+
/** The locked message once an action was refused, for a quiet status line. */
|
|
621
|
+
lockNote: spec.locked ? lockNote : null
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
function Toasts({
|
|
625
|
+
items,
|
|
626
|
+
announcement,
|
|
627
|
+
onDismiss,
|
|
628
|
+
prefix = "nf-g"
|
|
629
|
+
}) {
|
|
630
|
+
return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: `${prefix}-sr`, "aria-live": "polite", "aria-atomic": "true" }, /* @__PURE__ */ React.createElement("span", { key: announcement.n }, announcement.text)), items.length ? /* @__PURE__ */ React.createElement("div", { className: `${prefix}-toasts` }, items.map((t) => /* @__PURE__ */ React.createElement("div", { key: t.id, className: `${prefix}-toast`, "data-tone": t.tone, role: t.tone === "error" ? "alert" : "status" }, /* @__PURE__ */ React.createElement("span", { className: `${prefix}-toast-text` }, t.text), t.action ? /* @__PURE__ */ React.createElement(
|
|
631
|
+
"button",
|
|
632
|
+
{
|
|
633
|
+
type: "button",
|
|
634
|
+
className: `${prefix}-toast-btn`,
|
|
635
|
+
onClick: () => {
|
|
636
|
+
t.action.run();
|
|
637
|
+
onDismiss(t.id);
|
|
638
|
+
}
|
|
639
|
+
},
|
|
640
|
+
t.action.label
|
|
641
|
+
) : null, /* @__PURE__ */ React.createElement("button", { type: "button", className: `${prefix}-toast-x`, "aria-label": "Dismiss", onClick: () => onDismiss(t.id) }, "\xD7")))) : null);
|
|
642
|
+
}
|
|
643
|
+
function EmptyActionView({ action, prefix, buttonClass }) {
|
|
644
|
+
return /* @__PURE__ */ React2.createElement("div", { className: `${prefix}-emptyaction` }, action.busy ? /* @__PURE__ */ React2.createElement("span", { role: "status" }, action.status ?? `${action.label}\u2026`) : /* @__PURE__ */ React2.createElement("button", { type: "button", className: buttonClass, onClick: action.onClick }, action.label), action.hint && !action.busy ? /* @__PURE__ */ React2.createElement("small", null, action.hint) : null, action.error ? /* @__PURE__ */ React2.createElement("span", { role: "alert" }, action.error) : null);
|
|
645
|
+
}
|
|
646
|
+
var PLAN_WATERMARK_URL = "https://ninthforge.com/plan?ref=watermark";
|
|
647
|
+
function Watermark({ prefix, title, markHtml, variant = "mark", onClick, href = PLAN_WATERMARK_URL }) {
|
|
648
|
+
const mark = variant === "text" ? null : /* @__PURE__ */ React4.createElement("span", { "aria-hidden": "true", style: { display: "inline-flex" }, dangerouslySetInnerHTML: { __html: markHtml } });
|
|
649
|
+
return onClick ? /* @__PURE__ */ React4.createElement("button", { type: "button", className: `${prefix}-mark`, title, onClick }, mark, "Ninth Forge \xB7 Unlicensed") : /* @__PURE__ */ React4.createElement("a", { className: `${prefix}-mark`, href, target: "_blank", rel: "noreferrer", title }, mark, "Ninth Forge \xB7 Unlicensed");
|
|
650
|
+
}
|
|
651
|
+
var next = 0;
|
|
652
|
+
function nextUid(prefix) {
|
|
653
|
+
next += 1;
|
|
654
|
+
return `${prefix}${next.toString(36)}`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ../license/dist/chunk-Q7LEK2CU.js
|
|
658
|
+
var PREFIX = "NF1";
|
|
659
|
+
function utf8(s) {
|
|
660
|
+
return new TextEncoder().encode(s);
|
|
661
|
+
}
|
|
662
|
+
function b64urlDecode(s) {
|
|
663
|
+
if (!/^[A-Za-z0-9_-]*$/.test(s)) throw new Error("Invalid base64url");
|
|
664
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
|
|
665
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
666
|
+
const out = new Uint8Array(bin.length);
|
|
667
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
668
|
+
return out;
|
|
669
|
+
}
|
|
670
|
+
function hexToBytes(hex) {
|
|
671
|
+
if (!/^[0-9a-f]*$/i.test(hex) || hex.length % 2 !== 0) throw new Error("Invalid hex");
|
|
672
|
+
const out = new Uint8Array(hex.length / 2);
|
|
673
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
674
|
+
return out;
|
|
675
|
+
}
|
|
676
|
+
function subtle() {
|
|
677
|
+
const c = globalThis.crypto;
|
|
678
|
+
return c && c.subtle ? c.subtle : void 0;
|
|
679
|
+
}
|
|
680
|
+
var ED25519 = { name: "Ed25519" };
|
|
681
|
+
|
|
682
|
+
// ../license/dist/index.js
|
|
683
|
+
function normaliseHost(host) {
|
|
684
|
+
return host.trim().toLowerCase().replace(/\.+$/, "");
|
|
685
|
+
}
|
|
686
|
+
function browserHost() {
|
|
687
|
+
const loc = globalThis.location;
|
|
688
|
+
const name = loc && typeof loc.hostname === "string" ? normaliseHost(loc.hostname) : "";
|
|
689
|
+
return name || void 0;
|
|
690
|
+
}
|
|
691
|
+
function hostMatches(host, pattern) {
|
|
692
|
+
const h = normaliseHost(host);
|
|
693
|
+
const p = normaliseHost(pattern);
|
|
694
|
+
if (!h || !p) return false;
|
|
695
|
+
if (p.startsWith("*.")) {
|
|
696
|
+
const suffix = p.slice(1);
|
|
697
|
+
return suffix.length > 1 && h.length > suffix.length && h.endsWith(suffix);
|
|
698
|
+
}
|
|
699
|
+
return h === p;
|
|
700
|
+
}
|
|
701
|
+
var GRACE_DAYS = 14;
|
|
702
|
+
var SUITE_PRODUCTS = ["plan", "charts"];
|
|
703
|
+
var PUBLIC_KEYS = {
|
|
704
|
+
"nf-2027-1": "ad97c33249018681b3b315a303551442552d917d337690cac9b4783e931f88d1"
|
|
705
|
+
};
|
|
706
|
+
var DAY = 864e5;
|
|
707
|
+
function isoToUtcMs(iso) {
|
|
708
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
709
|
+
if (!m) return NaN;
|
|
710
|
+
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
711
|
+
}
|
|
712
|
+
function parsePayload(segment) {
|
|
713
|
+
try {
|
|
714
|
+
const json = JSON.parse(new TextDecoder().decode(b64urlDecode(segment)));
|
|
715
|
+
if (!json || typeof json.kid !== "string" || typeof json.exp !== "string") return void 0;
|
|
716
|
+
if (json.v === 1) return json;
|
|
717
|
+
if (json.v === 2 && Array.isArray(json.hosts) && json.hosts.length > 0 && json.hosts.every((h) => typeof h === "string" && h.trim() !== "")) {
|
|
718
|
+
return json;
|
|
719
|
+
}
|
|
720
|
+
} catch {
|
|
721
|
+
}
|
|
722
|
+
return void 0;
|
|
723
|
+
}
|
|
724
|
+
async function verifySignature(pubHex, message, signature) {
|
|
725
|
+
const s = subtle();
|
|
726
|
+
if (!s) return "unsupported";
|
|
727
|
+
let key;
|
|
728
|
+
try {
|
|
729
|
+
key = await s.importKey("raw", hexToBytes(pubHex), ED25519, false, ["verify"]);
|
|
730
|
+
} catch (err) {
|
|
731
|
+
return err?.name === "NotSupportedError" ? "unsupported" : false;
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
return await s.verify(ED25519, key, signature, message);
|
|
735
|
+
} catch {
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async function verifyLicense(key, opts) {
|
|
740
|
+
const trimmed = (key ?? "").trim();
|
|
741
|
+
if (!trimmed) return { status: "missing", licensed: false };
|
|
742
|
+
const parts2 = trimmed.split(".");
|
|
743
|
+
if (parts2.length !== 3 || parts2[0] !== PREFIX) return { status: "invalid", licensed: false };
|
|
744
|
+
const [, payloadSeg, sigSeg] = parts2;
|
|
745
|
+
const payload = parsePayload(payloadSeg);
|
|
746
|
+
const keys = opts.publicKeys ?? PUBLIC_KEYS;
|
|
747
|
+
const pubHex = payload ? keys[payload.kid] : void 0;
|
|
748
|
+
if (!payload || !pubHex) return { status: "invalid", licensed: false };
|
|
749
|
+
let sig;
|
|
750
|
+
try {
|
|
751
|
+
sig = b64urlDecode(sigSeg);
|
|
752
|
+
} catch {
|
|
753
|
+
return { status: "invalid", licensed: false };
|
|
754
|
+
}
|
|
755
|
+
const ok = await verifySignature(pubHex, utf8(payloadSeg), sig);
|
|
756
|
+
if (ok === false) return { status: "invalid", licensed: false };
|
|
757
|
+
if (payload.v === 2) {
|
|
758
|
+
const host = opts.host === void 0 ? "" : normaliseHost(opts.host);
|
|
759
|
+
const onHost = host !== "" && (payload.hosts ?? []).some((p) => hostMatches(host, p));
|
|
760
|
+
if (opts.tenantId !== void 0 && opts.tenantId !== null || !onHost) return { status: "wrong-host", licensed: false, payload };
|
|
761
|
+
}
|
|
762
|
+
if (ok === "unsupported") return { status: "unverifiable", licensed: true, payload };
|
|
763
|
+
const suiteCovers = payload.pid === "suite" && SUITE_PRODUCTS.includes(opts.product);
|
|
764
|
+
if (payload.pid !== opts.product && !suiteCovers) {
|
|
765
|
+
return { status: "wrong-product", licensed: false, payload };
|
|
766
|
+
}
|
|
767
|
+
const tenant = typeof opts.tenantId === "string" && opts.tenantId.trim() ? opts.tenantId : void 0;
|
|
768
|
+
if (payload.v === 1 && payload.tid !== "*" && tenant !== void 0 && payload.tid.toLowerCase() !== tenant.toLowerCase()) {
|
|
769
|
+
return { status: "wrong-tenant", licensed: false, payload };
|
|
770
|
+
}
|
|
771
|
+
const now = (opts.now ?? /* @__PURE__ */ new Date()).getTime();
|
|
772
|
+
const expMs = isoToUtcMs(payload.exp) + DAY;
|
|
773
|
+
if (Number.isNaN(expMs)) return { status: "invalid", licensed: false };
|
|
774
|
+
const daysLeft = Math.floor((expMs - now) / DAY);
|
|
775
|
+
if (now > expMs + GRACE_DAYS * DAY) return { status: "expired", licensed: false, payload, daysLeft };
|
|
776
|
+
if (now > expMs) return { status: "grace", licensed: true, payload, daysLeft };
|
|
777
|
+
return { status: "valid", licensed: true, payload, daysLeft };
|
|
778
|
+
}
|
|
779
|
+
var FAILURE_RANK = {
|
|
780
|
+
expired: 0,
|
|
781
|
+
"wrong-tenant": 1,
|
|
782
|
+
"wrong-host": 2,
|
|
783
|
+
"wrong-product": 3,
|
|
784
|
+
invalid: 4,
|
|
785
|
+
missing: 5
|
|
786
|
+
};
|
|
787
|
+
async function verifyLicenses(keys, opts) {
|
|
788
|
+
const list = (keys ?? "").split(/[\s,;]+/).filter(Boolean);
|
|
789
|
+
if (list.length <= 1) return verifyLicense(list[0], opts);
|
|
790
|
+
let best;
|
|
791
|
+
for (const key of list) {
|
|
792
|
+
const r = await verifyLicense(key, opts);
|
|
793
|
+
if (r.licensed) return r;
|
|
794
|
+
if (!best || (FAILURE_RANK[r.status] ?? 9) < (FAILURE_RANK[best.status] ?? 9)) best = r;
|
|
795
|
+
}
|
|
796
|
+
return best ?? { status: "missing", licensed: false };
|
|
797
|
+
}
|
|
798
|
+
var RENEW_URL = "https://ninthforge.com/licence?ref=renewal";
|
|
799
|
+
function renewalNotice(result, now = /* @__PURE__ */ new Date()) {
|
|
800
|
+
if (!result || result.status !== "grace" || !result.payload) return null;
|
|
801
|
+
const expMs = isoToUtcMs(result.payload.exp) + DAY;
|
|
802
|
+
if (Number.isNaN(expMs)) return null;
|
|
803
|
+
const daysLeft = Math.min(GRACE_DAYS, Math.max(1, Math.ceil((expMs + GRACE_DAYS * DAY - now.getTime()) / DAY)));
|
|
804
|
+
const days = daysLeft === 1 ? "1 day" : `${daysLeft} days`;
|
|
805
|
+
return {
|
|
806
|
+
text: `Licence expired \xB7 renew within ${days}`,
|
|
807
|
+
detail: `Your Ninth Forge licence expired on ${result.payload.exp}. Renew within ${days} to keep the watermark off. Only makers see this.`,
|
|
808
|
+
href: RENEW_URL,
|
|
809
|
+
daysLeft
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
function licenseMessage(result) {
|
|
813
|
+
switch (result.status) {
|
|
814
|
+
case "valid":
|
|
815
|
+
case "unverifiable":
|
|
816
|
+
return `Licensed to ${result.payload?.org ?? "your organisation"}`;
|
|
817
|
+
case "grace":
|
|
818
|
+
return "Licence expired \u2014 renew within 14 days to keep the watermark off";
|
|
819
|
+
case "expired":
|
|
820
|
+
return "Licence expired";
|
|
821
|
+
case "missing":
|
|
822
|
+
return "Unlicensed \u2014 add a licence key in the control properties";
|
|
823
|
+
case "wrong-product":
|
|
824
|
+
return "This key is for a different Ninth Forge product";
|
|
825
|
+
case "wrong-tenant":
|
|
826
|
+
return "This key belongs to a different Microsoft tenant";
|
|
827
|
+
case "wrong-host":
|
|
828
|
+
return `This demo key only works on ${result.payload?.hosts?.[0] ?? "the Ninth Forge website"}`;
|
|
829
|
+
default:
|
|
830
|
+
return "Licence key not recognised";
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
var PAID_TIERS = ["team", "business", "enterprise"];
|
|
834
|
+
function unlocksPro(r) {
|
|
835
|
+
if (!r) return false;
|
|
836
|
+
if (r.licensed) return true;
|
|
837
|
+
return r.status === "expired" && !!r.payload && PAID_TIERS.includes(r.payload.tier);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ../tokens/dist/index.js
|
|
841
|
+
var TEXT_ROLES = ["caption", "body", "label", "value", "callout", "header", "title", "hero"];
|
|
842
|
+
var TEXT_SIZES = {
|
|
843
|
+
caption: 11,
|
|
844
|
+
body: 13,
|
|
845
|
+
label: 12,
|
|
846
|
+
value: 12,
|
|
847
|
+
callout: 12,
|
|
848
|
+
header: 13,
|
|
849
|
+
title: 16,
|
|
850
|
+
hero: 28
|
|
851
|
+
};
|
|
852
|
+
var TEXT_SCALE_MIN = 0.75;
|
|
853
|
+
var TEXT_SCALE_MAX = 2;
|
|
854
|
+
function clampTextScale(scale) {
|
|
855
|
+
if (typeof scale !== "number" || !Number.isFinite(scale) || scale <= 0) return 1;
|
|
856
|
+
return Math.min(TEXT_SCALE_MAX, Math.max(TEXT_SCALE_MIN, scale));
|
|
857
|
+
}
|
|
858
|
+
var half = (px) => Math.round(px * 2) / 2;
|
|
859
|
+
function textSize(role, scale = 1, sizes) {
|
|
860
|
+
const base = sizes?.[role] ?? TEXT_SIZES[role];
|
|
861
|
+
return half(base * clampTextScale(scale));
|
|
862
|
+
}
|
|
863
|
+
function textVars(scale = 1, sizes) {
|
|
864
|
+
const out = {};
|
|
865
|
+
for (const role of TEXT_ROLES) out[`--nf-text-${role}`] = `${textSize(role, scale, sizes)}px`;
|
|
866
|
+
return out;
|
|
867
|
+
}
|
|
868
|
+
function scaleWithText(px, scale = 1) {
|
|
869
|
+
const s = clampTextScale(scale);
|
|
870
|
+
if (s === 1) return px;
|
|
871
|
+
return Math.ceil(px * s / 8) * 8;
|
|
872
|
+
}
|
|
873
|
+
var brand = {
|
|
874
|
+
ink: "#0E1116",
|
|
875
|
+
ember: "#D9480F",
|
|
876
|
+
paper: "#F6F6F3",
|
|
877
|
+
line: "#E4E5E9",
|
|
878
|
+
muted: "#5D6472",
|
|
879
|
+
cobalt: "#2E54FF"
|
|
880
|
+
};
|
|
881
|
+
var semantic = {
|
|
882
|
+
milestone: "#E8930C",
|
|
883
|
+
positive: "#138A52",
|
|
884
|
+
negative: "#D93F2B"
|
|
885
|
+
};
|
|
886
|
+
var font = {
|
|
887
|
+
sans: "'Geist', 'Segoe UI', system-ui, -apple-system, sans-serif",
|
|
888
|
+
mono: "'Geist Mono', 'Cascadia Mono', ui-monospace, monospace",
|
|
889
|
+
/** Components inherit the host app's font unless a theme says otherwise. */
|
|
890
|
+
inherit: "inherit"
|
|
891
|
+
};
|
|
892
|
+
var motion = {
|
|
893
|
+
fast: "150ms",
|
|
894
|
+
base: "220ms",
|
|
895
|
+
ease: "cubic-bezier(0.2, 0.8, 0.2, 1)"
|
|
896
|
+
};
|
|
897
|
+
var lightTheme = {
|
|
898
|
+
accent: brand.cobalt,
|
|
899
|
+
accentTint: "#DCE3FF",
|
|
900
|
+
accentText: "#FFFFFF",
|
|
901
|
+
surface: "#FFFFFF",
|
|
902
|
+
surfaceAlt: "#F6F6F3",
|
|
903
|
+
ink: brand.ink,
|
|
904
|
+
muted: brand.muted,
|
|
905
|
+
line: brand.line,
|
|
906
|
+
lineSoft: "#F2F3F5",
|
|
907
|
+
phase: brand.ink,
|
|
908
|
+
milestone: semantic.milestone,
|
|
909
|
+
baseline: "#B9BDC7",
|
|
910
|
+
selected: "#F4F6FF",
|
|
911
|
+
focus: brand.cobalt,
|
|
912
|
+
positive: semantic.positive,
|
|
913
|
+
negative: semantic.negative,
|
|
914
|
+
tooltipBg: brand.ink,
|
|
915
|
+
tooltipInk: "#FFFFFF",
|
|
916
|
+
font: font.inherit,
|
|
917
|
+
monoFont: font.mono
|
|
918
|
+
};
|
|
919
|
+
var darkTheme = {
|
|
920
|
+
accent: "#6F8BFF",
|
|
921
|
+
accentTint: "#28305A",
|
|
922
|
+
accentText: "#0E1116",
|
|
923
|
+
surface: "#14171D",
|
|
924
|
+
surfaceAlt: "#0E1116",
|
|
925
|
+
ink: "#F2F3F5",
|
|
926
|
+
muted: "#A3A9B6",
|
|
927
|
+
line: "#2A2F3A",
|
|
928
|
+
lineSoft: "#1D2129",
|
|
929
|
+
phase: "#E4E5E9",
|
|
930
|
+
milestone: "#F2A53A",
|
|
931
|
+
baseline: "#5D6472",
|
|
932
|
+
selected: "#1C2238",
|
|
933
|
+
focus: "#8FA6FF",
|
|
934
|
+
positive: "#3FBF7F",
|
|
935
|
+
negative: "#F06A58",
|
|
936
|
+
tooltipBg: "#F2F3F5",
|
|
937
|
+
tooltipInk: "#0E1116",
|
|
938
|
+
font: font.inherit,
|
|
939
|
+
monoFont: font.mono
|
|
940
|
+
};
|
|
941
|
+
var kebab = (s) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
942
|
+
function themeVars(theme = {}, base = lightTheme) {
|
|
943
|
+
const merged = { ...base, ...theme };
|
|
944
|
+
const out = {};
|
|
945
|
+
Object.keys(merged).forEach((k) => {
|
|
946
|
+
out[`--nf-${kebab(k)}`] = merged[k];
|
|
947
|
+
});
|
|
948
|
+
return out;
|
|
949
|
+
}
|
|
950
|
+
function markSvg({ size = 40, tile = true, ink = brand.ink, ember = brand.ember } = {}) {
|
|
951
|
+
const stroke = tile ? "#FFFFFF" : ink;
|
|
952
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 40 40" role="img" aria-label="Ninth Forge">` + (tile ? `<rect width="40" height="40" rx="10" fill="${ink}"/>` : "") + `<rect x="8.5" y="10" width="4.6" height="20" fill="${stroke}"/><path d="M17 10 L31.5 30" stroke="${stroke}" stroke-width="4.6"/><path d="M31.5 10 L17 30" stroke="${stroke}" stroke-width="4.6"/><path d="M31.5 10 L27.6 15.4" stroke="${ember}" stroke-width="4.6"/></svg>`;
|
|
953
|
+
}
|
|
954
|
+
function luminance(hex) {
|
|
955
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
|
956
|
+
if (!m) return null;
|
|
957
|
+
const h = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1];
|
|
958
|
+
const ch = [0, 2, 4].map((i) => {
|
|
959
|
+
const v = parseInt(h.slice(i, i + 2), 16) / 255;
|
|
960
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
961
|
+
});
|
|
962
|
+
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
|
|
963
|
+
}
|
|
964
|
+
function contrastRatio(a, b) {
|
|
965
|
+
const la = luminance(a);
|
|
966
|
+
const lb = luminance(b);
|
|
967
|
+
if (la === null || lb === null) return null;
|
|
968
|
+
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
|
969
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
970
|
+
}
|
|
971
|
+
function readableTextOn(background, light = "#FFFFFF", dark = brand.ink) {
|
|
972
|
+
const onLight = contrastRatio(background, light);
|
|
973
|
+
const onDark = contrastRatio(background, dark);
|
|
974
|
+
if (onLight === null || onDark === null) return light;
|
|
975
|
+
return onLight >= onDark ? light : dark;
|
|
976
|
+
}
|
|
977
|
+
function mixHex(a, b, t) {
|
|
978
|
+
const p = (h) => {
|
|
979
|
+
const s = h.replace("#", "");
|
|
980
|
+
const f = s.length === 3 ? s.replace(/./g, (c) => c + c) : s;
|
|
981
|
+
return [0, 2, 4].map((i) => parseInt(f.slice(i, i + 2), 16));
|
|
982
|
+
};
|
|
983
|
+
const [x, y] = [p(a), p(b)];
|
|
984
|
+
return "#" + x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
985
|
+
}
|
|
986
|
+
function accentForText(accent, light = "#FFFFFF", dark = brand.ink) {
|
|
987
|
+
const text = readableTextOn(accent, light, dark);
|
|
988
|
+
if ((contrastRatio(accent, text) ?? 21) >= 4.5) return { background: accent, text };
|
|
989
|
+
const toward = text === light ? "#000000" : "#FFFFFF";
|
|
990
|
+
for (let t = 0.05; t <= 1.0001; t += 0.05) {
|
|
991
|
+
const bg = mixHex(accent, toward, t);
|
|
992
|
+
if ((contrastRatio(bg, text) ?? 0) >= 4.5) return { background: bg, text };
|
|
993
|
+
}
|
|
994
|
+
return { background: toward === "#000000" ? "#000000" : "#FFFFFF", text };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// src/layout.ts
|
|
998
|
+
var ROW_H = 30;
|
|
999
|
+
var LANE_PAD = 8;
|
|
1000
|
+
var LANE_MIN_H = 48;
|
|
1001
|
+
var LANE_HEAD_W = 200;
|
|
1002
|
+
var NO_LANE = "No lane";
|
|
1003
|
+
var TEXT = ["name", "start", "end", "lane", "status", "horizon", "color"];
|
|
1004
|
+
var asText = (v) => v === null || v === void 0 ? void 0 : typeof v === "string" ? v : String(v);
|
|
1005
|
+
function cleanItems(items) {
|
|
1006
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1007
|
+
const out = [];
|
|
1008
|
+
for (const it of items) {
|
|
1009
|
+
if (!it || typeof it !== "object") continue;
|
|
1010
|
+
const raw = it;
|
|
1011
|
+
const id = asText(raw.id);
|
|
1012
|
+
if (!id || seen.has(id)) continue;
|
|
1013
|
+
seen.add(id);
|
|
1014
|
+
const deps = raw.dependsOn;
|
|
1015
|
+
const clean2 = typeof raw.id === "string" && typeof raw.name === "string" && TEXT.every((k) => raw[k] === void 0 || typeof raw[k] === "string") && (deps === void 0 || Array.isArray(deps) && deps.every((d) => typeof d === "string")) && (raw.milestone === void 0 || typeof raw.milestone === "boolean");
|
|
1016
|
+
if (clean2) {
|
|
1017
|
+
out.push(it);
|
|
1018
|
+
continue;
|
|
1019
|
+
}
|
|
1020
|
+
const fixed = { ...raw, id };
|
|
1021
|
+
for (const k of TEXT) fixed[k] = asText(raw[k]);
|
|
1022
|
+
fixed.name = asText(raw.name) ?? "";
|
|
1023
|
+
fixed.dependsOn = Array.isArray(deps) ? deps.map(asText).filter((d) => !!d) : void 0;
|
|
1024
|
+
fixed.milestone = typeof raw.milestone === "boolean" ? raw.milestone : void 0;
|
|
1025
|
+
out.push(fixed);
|
|
1026
|
+
}
|
|
1027
|
+
return out;
|
|
1028
|
+
}
|
|
1029
|
+
function dayOfIso(v) {
|
|
1030
|
+
if (typeof v !== "string") return NaN;
|
|
1031
|
+
const m = /^(\d{4}-\d{2}-\d{2})/.exec(v);
|
|
1032
|
+
if (!m) return NaN;
|
|
1033
|
+
const d = toDay(m[1]);
|
|
1034
|
+
return Number.isFinite(d) ? d : NaN;
|
|
1035
|
+
}
|
|
1036
|
+
function place(item) {
|
|
1037
|
+
let start = dayOfIso(item.start);
|
|
1038
|
+
let end = dayOfIso(item.end);
|
|
1039
|
+
if (Number.isNaN(start) && Number.isNaN(end)) return null;
|
|
1040
|
+
if (Number.isNaN(start)) start = end;
|
|
1041
|
+
if (Number.isNaN(end)) end = start;
|
|
1042
|
+
if (end < start) [start, end] = [end, start];
|
|
1043
|
+
const milestone = item.milestone === void 0 ? end === start : item.milestone;
|
|
1044
|
+
return { item, id: item.id, start, end: milestone ? start : end, milestone };
|
|
1045
|
+
}
|
|
1046
|
+
function packRows(spans) {
|
|
1047
|
+
const sorted = [...spans].sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start) || a.id.localeCompare(b.id));
|
|
1048
|
+
const ends = [];
|
|
1049
|
+
const row = /* @__PURE__ */ new Map();
|
|
1050
|
+
for (const s of sorted) {
|
|
1051
|
+
let i = ends.findIndex((e2) => e2 < s.start);
|
|
1052
|
+
if (i < 0) {
|
|
1053
|
+
i = ends.length;
|
|
1054
|
+
ends.push(s.end);
|
|
1055
|
+
} else ends[i] = s.end;
|
|
1056
|
+
row.set(s.id, i);
|
|
1057
|
+
}
|
|
1058
|
+
return { row, rows: Math.max(1, ends.length) };
|
|
1059
|
+
}
|
|
1060
|
+
function labelWidth(text, textScale = 1) {
|
|
1061
|
+
return Math.ceil(text.length * 6.6 * textScale) + 12;
|
|
1062
|
+
}
|
|
1063
|
+
var LABEL_INSIDE_MIN = 64;
|
|
1064
|
+
function footprint(p, scale, textScale = 1) {
|
|
1065
|
+
const barPx = p.milestone ? 14 : (p.end - p.start + 1) * scale.dayWidth;
|
|
1066
|
+
const outside = p.milestone || barPx < LABEL_INSIDE_MIN * textScale;
|
|
1067
|
+
if (!outside) return { id: p.id, start: p.start, end: p.end };
|
|
1068
|
+
const px = (p.milestone ? 14 : barPx) + 6 + labelWidth(p.item.name, textScale);
|
|
1069
|
+
return { id: p.id, start: p.start, end: Math.max(p.end, p.start + Math.ceil(px / scale.dayWidth)) };
|
|
1070
|
+
}
|
|
1071
|
+
function laneHeight(rows, collapsed, textScale = 1) {
|
|
1072
|
+
const rowH = scaleWithText(ROW_H, textScale);
|
|
1073
|
+
return collapsed ? scaleWithText(36, textScale) : Math.max(scaleWithText(LANE_MIN_H, textScale), LANE_PAD * 2 + rows * rowH);
|
|
1074
|
+
}
|
|
1075
|
+
var norm = (s) => s.trim().toLowerCase();
|
|
1076
|
+
function matches(item, filter) {
|
|
1077
|
+
const q = norm(filter);
|
|
1078
|
+
if (!q) return true;
|
|
1079
|
+
return [item.name, item.lane, item.status].some((v) => (v ?? "").toLowerCase().includes(q));
|
|
1080
|
+
}
|
|
1081
|
+
function laneKeys(items, laneOrder = []) {
|
|
1082
|
+
const present = new Set(items.map((i) => (i.lane ?? "").trim() || NO_LANE));
|
|
1083
|
+
const ordered = laneOrder.filter((l) => present.has(l));
|
|
1084
|
+
const rest = [...present].filter((l) => !ordered.includes(l) && l !== NO_LANE).sort((a, b) => a.localeCompare(b));
|
|
1085
|
+
return [...ordered, ...rest, ...present.has(NO_LANE) ? [NO_LANE] : []];
|
|
1086
|
+
}
|
|
1087
|
+
function buildLanes(items, scale, opts = {}) {
|
|
1088
|
+
const shown = items.filter((i) => matches(i, opts.filter ?? ""));
|
|
1089
|
+
const unscheduled = [];
|
|
1090
|
+
const byLane = /* @__PURE__ */ new Map();
|
|
1091
|
+
for (const it of shown) {
|
|
1092
|
+
const p = place(it);
|
|
1093
|
+
if (!p) {
|
|
1094
|
+
unscheduled.push(it);
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
const key = (it.lane ?? "").trim() || NO_LANE;
|
|
1098
|
+
const list = byLane.get(key) ?? [];
|
|
1099
|
+
list.push(p);
|
|
1100
|
+
byLane.set(key, list);
|
|
1101
|
+
}
|
|
1102
|
+
const lanes = [];
|
|
1103
|
+
let top = 0;
|
|
1104
|
+
for (const key of laneKeys(shown, opts.laneOrder)) {
|
|
1105
|
+
const placed = (byLane.get(key) ?? []).sort((a, b) => a.start - b.start || a.id.localeCompare(b.id));
|
|
1106
|
+
const { row, rows } = packRows(placed.map((p) => footprint(p, scale, opts.textScale)));
|
|
1107
|
+
const collapsed = opts.collapsed?.has(key) ?? false;
|
|
1108
|
+
const height = laneHeight(rows, collapsed, opts.textScale);
|
|
1109
|
+
lanes.push({ key, label: key, items: placed, row, rows, collapsed, top, height, index: lanes.length });
|
|
1110
|
+
top += height;
|
|
1111
|
+
}
|
|
1112
|
+
return { lanes, unscheduled: unscheduled.sort((a, b) => a.name.localeCompare(b.name)) };
|
|
1113
|
+
}
|
|
1114
|
+
var PALETTE = ["#2E54FF", "#0E8C7F", "#6B4EFF", "#B54708", "#0B6E99", "#9E1F63"];
|
|
1115
|
+
var SEMANTIC = [
|
|
1116
|
+
[/^(on track|green|healthy|in progress|active)$/i, "positive"],
|
|
1117
|
+
[/^(at risk|amber|yellow|watch)$/i, "warning"],
|
|
1118
|
+
[/^(off track|red|blocked|late|delayed)$/i, "negative"],
|
|
1119
|
+
[/^(done|complete|completed|shipped|delivered|closed)$/i, "done"],
|
|
1120
|
+
[/^(not started|planned|backlog|proposed|idea)$/i, "planned"]
|
|
1121
|
+
];
|
|
1122
|
+
var SEMANTIC_COLOR = { positive: semantic.positive, warning: semantic.milestone, negative: semantic.negative };
|
|
1123
|
+
var DONE_COLOR = "#8A90A0";
|
|
1124
|
+
function appearance(item, o) {
|
|
1125
|
+
const status = (item.status ?? "").trim();
|
|
1126
|
+
const meaning = SEMANTIC.find(([re]) => re.test(status))?.[1];
|
|
1127
|
+
const style = meaning === "done" ? "done" : meaning === "planned" ? "outline" : "solid";
|
|
1128
|
+
const value = o.colorBy === "lane" ? (item.lane ?? "").trim() || NO_LANE : status;
|
|
1129
|
+
const map = o.colorBy === "lane" ? o.laneColors : o.statusColors;
|
|
1130
|
+
let color = item.color || map?.[value];
|
|
1131
|
+
if (!color && o.colorBy === "status" && meaning && meaning !== "done" && meaning !== "planned") color = SEMANTIC_COLOR[meaning];
|
|
1132
|
+
if (!color && o.colorBy === "status" && meaning === "done") color = DONE_COLOR;
|
|
1133
|
+
if (!color && o.colorBy === "status" && (!status || meaning)) color = o.accent;
|
|
1134
|
+
if (!color) {
|
|
1135
|
+
color = o.seen.get(value);
|
|
1136
|
+
if (!color) {
|
|
1137
|
+
color = PALETTE[o.seen.size % PALETTE.length];
|
|
1138
|
+
o.seen.set(value, color);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
const pair = accentForText(color);
|
|
1142
|
+
return { color: pair.background, text: pair.text, style };
|
|
1143
|
+
}
|
|
1144
|
+
var HORIZONS = ["Now", "Next", "Later"];
|
|
1145
|
+
function horizonColumns(items) {
|
|
1146
|
+
const cols = HORIZONS.map((h) => ({ key: h, items: [] }));
|
|
1147
|
+
const other = [];
|
|
1148
|
+
for (const it of items) {
|
|
1149
|
+
const i = HORIZONS.findIndex((h) => h.toLowerCase() === (it.horizon ?? "").trim().toLowerCase());
|
|
1150
|
+
(i >= 0 ? cols[i].items : other).push(it);
|
|
1151
|
+
}
|
|
1152
|
+
return other.length ? [...cols, { key: "No horizon", items: other }] : cols;
|
|
1153
|
+
}
|
|
1154
|
+
function stepDay(day, zoom, dir) {
|
|
1155
|
+
if (zoom === "month") return day + dir * 7;
|
|
1156
|
+
const p = parts(day);
|
|
1157
|
+
const first = addMonths(day, dir);
|
|
1158
|
+
const last = addMonths(first, 1) - 1;
|
|
1159
|
+
return Math.min(dayOf(parts(first).year, parts(first).month, p.date), last);
|
|
1160
|
+
}
|
|
1161
|
+
function isoOfDay(day) {
|
|
1162
|
+
return new Date(day * 864e5).toISOString().slice(0, 10);
|
|
1163
|
+
}
|
|
1164
|
+
function connectorPath(fx, fy, tx, ty, rowH = ROW_H) {
|
|
1165
|
+
const GAP = 12;
|
|
1166
|
+
if (tx - fx >= GAP || fy === ty) {
|
|
1167
|
+
const mid = fy === ty ? tx - 1 : fx + Math.min(GAP, (tx - fx) / 2);
|
|
1168
|
+
return fy === ty ? `M${fx},${fy}H${tx - 1}` : `M${fx},${fy}H${mid}V${ty}H${tx - 1}`;
|
|
1169
|
+
}
|
|
1170
|
+
const between = ty > fy ? ty - rowH / 2 : ty + rowH / 2;
|
|
1171
|
+
return `M${fx},${fy}H${fx + 6}V${between}H${tx - 8}V${ty}H${tx - 1}`;
|
|
1172
|
+
}
|
|
1173
|
+
var MAX_SPAN_DAYS = Math.round(15 * 365.25);
|
|
1174
|
+
function timelineRange(placed, todayDay) {
|
|
1175
|
+
const days = [];
|
|
1176
|
+
for (const p of placed) {
|
|
1177
|
+
if (!p) continue;
|
|
1178
|
+
if (Number.isFinite(p.start)) days.push(p.start);
|
|
1179
|
+
if (Number.isFinite(p.end)) days.push(p.end);
|
|
1180
|
+
}
|
|
1181
|
+
if (!days.length) return { from: todayDay - 60, to: todayDay + 300 };
|
|
1182
|
+
days.push(todayDay);
|
|
1183
|
+
days.sort((a, b) => a - b);
|
|
1184
|
+
let lo = days[0];
|
|
1185
|
+
let hi = days[days.length - 1];
|
|
1186
|
+
if (hi - lo > MAX_SPAN_DAYS) {
|
|
1187
|
+
const mid = days[Math.floor(days.length / 2)];
|
|
1188
|
+
lo = Math.max(lo, Math.min(mid - MAX_SPAN_DAYS / 2, hi - MAX_SPAN_DAYS));
|
|
1189
|
+
hi = lo + MAX_SPAN_DAYS;
|
|
1190
|
+
}
|
|
1191
|
+
return { from: lo - 14, to: hi + 30 };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// src/edit.ts
|
|
1195
|
+
var TIMELINE_FIELDS = {
|
|
1196
|
+
keys: ["start", "end"],
|
|
1197
|
+
equal: (_key, a, b) => (typeof a === "string" ? a.slice(0, 10) : a ?? "") === (typeof b === "string" ? b.slice(0, 10) : b ?? "")
|
|
1198
|
+
};
|
|
1199
|
+
function dayLabel(day) {
|
|
1200
|
+
const p = parts(day);
|
|
1201
|
+
return `${p.date} ${MONTHS[p.month]} ${p.year}`;
|
|
1202
|
+
}
|
|
1203
|
+
function rangeLabel(start, end) {
|
|
1204
|
+
if (start === end) return dayLabel(start);
|
|
1205
|
+
const a = parts(start);
|
|
1206
|
+
const b = parts(end);
|
|
1207
|
+
const left = a.year === b.year ? `${a.date} ${MONTHS[a.month]}` : dayLabel(start);
|
|
1208
|
+
return `${left} \u2013 ${dayLabel(end)}`;
|
|
1209
|
+
}
|
|
1210
|
+
function itemWords(p, extra) {
|
|
1211
|
+
const when = p.milestone ? `milestone on ${dayLabel(p.start)}` : p.start === p.end ? `on ${dayLabel(p.start)}` : `${dayLabel(p.start)} to ${dayLabel(p.end)}`;
|
|
1212
|
+
const deps = extra.dependsOn?.length ? `depends on ${extra.dependsOn.join(", ")}` : "";
|
|
1213
|
+
return [p.item.name, extra.lane, when, extra.status ?? "", deps].filter(Boolean).join(", ");
|
|
1214
|
+
}
|
|
1215
|
+
function canEdit(editable, hasHandler, action, item) {
|
|
1216
|
+
if (!hasHandler || editable === false || item?.readOnly) return false;
|
|
1217
|
+
if (editable && typeof editable === "object" && editable[action] === false) return false;
|
|
1218
|
+
return true;
|
|
1219
|
+
}
|
|
1220
|
+
function dragSpan(mode, start, end, days) {
|
|
1221
|
+
if (mode === "move") return { start: start + days, end: end + days };
|
|
1222
|
+
if (mode === "start") return { start: Math.min(start + days, end), end };
|
|
1223
|
+
return { start, end: Math.max(end + days, start) };
|
|
1224
|
+
}
|
|
1225
|
+
function keyEdit(e2) {
|
|
1226
|
+
if (!e2.altKey || e2.ctrlKey || e2.metaKey) return null;
|
|
1227
|
+
if (e2.key !== "ArrowLeft" && e2.key !== "ArrowRight") return null;
|
|
1228
|
+
return { mode: e2.shiftKey ? "end" : "move", dir: e2.key === "ArrowRight" ? 1 : -1 };
|
|
1229
|
+
}
|
|
1230
|
+
function keySpan(mode, start, end, zoom, dir) {
|
|
1231
|
+
if (mode === "end") return { start, end: Math.max(stepDay(end, zoom, dir), start) };
|
|
1232
|
+
const s = stepDay(start, zoom, dir);
|
|
1233
|
+
return { start: s, end: s + (end - start) };
|
|
1234
|
+
}
|
|
1235
|
+
function spanChange(p, span, source) {
|
|
1236
|
+
const item = p.item;
|
|
1237
|
+
const next2 = {};
|
|
1238
|
+
const previous = {};
|
|
1239
|
+
if (span.start !== p.start) {
|
|
1240
|
+
next2.start = isoOfDay(span.start);
|
|
1241
|
+
previous.start = item.start;
|
|
1242
|
+
}
|
|
1243
|
+
const wantEnd = p.milestone ? item.end ? span.start : void 0 : span.end;
|
|
1244
|
+
if (wantEnd !== void 0 && wantEnd !== p.end) {
|
|
1245
|
+
next2.end = isoOfDay(wantEnd);
|
|
1246
|
+
previous.end = item.end;
|
|
1247
|
+
}
|
|
1248
|
+
if (next2.start === void 0 && next2.end === void 0) return null;
|
|
1249
|
+
return { id: item.id, ...next2, previous, source };
|
|
1250
|
+
}
|
|
1251
|
+
function describeChange(change, name, before) {
|
|
1252
|
+
const start = change.start ?? (before ? isoOfDay(before.start) : void 0);
|
|
1253
|
+
const end = change.end ?? (before ? isoOfDay(before.end) : void 0);
|
|
1254
|
+
const s = start ? Date.parse(`${start.slice(0, 10)}T00:00:00Z`) / 864e5 : NaN;
|
|
1255
|
+
const e2 = end ? Date.parse(`${end.slice(0, 10)}T00:00:00Z`) / 864e5 : s;
|
|
1256
|
+
const prefix = change.source === "undo" ? "Undone: " : change.source === "redo" ? "Redone: " : "";
|
|
1257
|
+
if (Number.isNaN(s)) return `${prefix}'${name}' changed`;
|
|
1258
|
+
if (before?.milestone) return `${prefix}'${name}' moved to ${dayLabel(s)}`;
|
|
1259
|
+
const verb = change.start !== void 0 ? "moved" : "now ends";
|
|
1260
|
+
return prefix + (verb === "moved" ? `'${name}' moved to ${rangeLabel(s, e2)}` : `'${name}' now ends ${dayLabel(e2)}`);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/styles.ts
|
|
1264
|
+
var HEAD_H = 52;
|
|
1265
|
+
var MONTH_ROW_H = 20;
|
|
1266
|
+
var css = `
|
|
1267
|
+
.nf-tl{--nf-lanehead:${LANE_HEAD_W}px;--nf-row:${ROW_H}px;--nf-now:${brand.ember};position:relative;isolation:isolate;display:flex;flex-direction:column;width:100%;box-sizing:border-box;min-height:0;height:100%;box-sizing:border-box;background:var(--nf-surface);color:var(--nf-ink);font-family:var(--nf-font);font-size:var(--nf-text-body);line-height:1.3;border:1px solid var(--nf-line);border-radius:16px;overflow:hidden;-webkit-font-smoothing:antialiased}
|
|
1268
|
+
.nf-tl *,.nf-tl *::before,.nf-tl *::after{box-sizing:border-box}
|
|
1269
|
+
.nf-t-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-height:60px;padding:10px 16px;border-bottom:1px solid var(--nf-line)}
|
|
1270
|
+
.nf-t-title{font-weight:600;font-size:var(--nf-text-title);letter-spacing:-0.01em;margin-inline-end:6px}
|
|
1271
|
+
.nf-t-seg{display:inline-flex;gap:2px;padding:3px;border-radius:9px;background:var(--nf-line-soft);max-width:100%;overflow-x:auto}
|
|
1272
|
+
.nf-t-btn{font:inherit;font-size:var(--nf-text-body);border:1px solid var(--nf-line);background:var(--nf-surface);color:var(--nf-ink);padding:7px 12px;border-radius:9px;cursor:pointer;min-height:32px;white-space:nowrap;transition:background ${motion.fast} ${motion.ease}}
|
|
1273
|
+
.nf-t-btn:hover{background:var(--nf-line-soft)}
|
|
1274
|
+
.nf-t-seg .nf-t-btn{flex:none;border:0;background:transparent;color:var(--nf-muted);padding:6px 12px;border-radius:7px}
|
|
1275
|
+
.nf-t-seg .nf-t-btn[aria-pressed="true"]{background:var(--nf-surface);color:var(--nf-ink);font-weight:500;box-shadow:0 1px 2px rgba(14,17,22,.12)}
|
|
1276
|
+
.nf-t-spacer{flex:1}
|
|
1277
|
+
.nf-t-filter{font:inherit;font-size:var(--nf-text-body);min-height:32px;width:calc(var(--nf-text-body) * 170 / 13);max-width:40vw;padding:6px 10px;border-radius:9px;border:1px solid var(--nf-line);background:var(--nf-surface);color:var(--nf-ink)}
|
|
1278
|
+
.nf-t-count{display:inline-block;min-width:20px;padding:1px 6px;margin-inline-start:6px;border-radius:9px;background:var(--nf-accent-solid,var(--nf-accent));color:var(--nf-accent-text);font-size:var(--nf-text-caption);font-weight:600;text-align:center;direction:ltr;unicode-bidi:isolate}
|
|
1279
|
+
.nf-t-btn:focus-visible,.nf-t-filter:focus-visible,.nf-t-scroll:focus-visible,.nf-t-lanebtn:focus-visible,.nf-t-card:focus-visible,.nf-t-trayitem:focus-visible{outline:2px solid var(--nf-focus);outline-offset:2px}
|
|
1280
|
+
.nf-t-main{position:relative;flex:1;min-height:0;display:flex}
|
|
1281
|
+
.nf-t-scroll{position:relative;flex:1;min-width:0;overflow:auto;outline:none}
|
|
1282
|
+
.nf-t-head{position:sticky;top:0;z-index:4;display:flex;height:var(--nf-head,${HEAD_H}px);background:var(--nf-surface);border-bottom:1px solid var(--nf-line)}
|
|
1283
|
+
.nf-t-corner{position:sticky;left:0;z-index:5;flex:none;width:var(--nf-lanehead);display:flex;align-items:flex-end;padding:0 16px 8px;background:var(--nf-surface);border-right:1px solid var(--nf-line);font-family:var(--nf-mono-font);font-size:var(--nf-text-caption);letter-spacing:.06em;color:var(--nf-muted);text-transform:uppercase}
|
|
1284
|
+
.nf-t-axis{position:relative;flex:none}
|
|
1285
|
+
.nf-t-major,.nf-t-minor{position:absolute;padding:0 8px;white-space:nowrap;overflow:hidden;border-left:1px solid var(--nf-line)}
|
|
1286
|
+
.nf-t-major{top:0;height:calc(var(--nf-text-label) * 2);padding-top:6px;font-size:var(--nf-text-label);font-weight:600}
|
|
1287
|
+
.nf-t-minor{top:calc(var(--nf-text-label) * 26 / 12);height:calc(var(--nf-text-label) * 26 / 12);padding-top:6px;font-family:var(--nf-mono-font);font-size:var(--nf-text-caption);color:var(--nf-muted)}
|
|
1288
|
+
.nf-tl[data-months] .nf-t-minor{font-family:var(--nf-font);font-size:var(--nf-text-label);font-weight:600;color:var(--nf-ink)}
|
|
1289
|
+
.nf-t-month{position:absolute;top:var(--nf-head,52px);height:calc(var(--nf-text-caption) * 20 / 11);padding:3px 0 0 8px;white-space:nowrap;overflow:hidden;border-left:1px solid var(--nf-line-soft);font-family:var(--nf-mono-font);font-size:var(--nf-text-caption);color:var(--nf-muted)}
|
|
1290
|
+
.nf-t-body{position:relative}
|
|
1291
|
+
.nf-t-layer{position:absolute;top:0;bottom:0;pointer-events:none}
|
|
1292
|
+
.nf-t-vline{position:absolute;top:0;bottom:0;width:1px;background:var(--nf-line-soft)}
|
|
1293
|
+
.nf-t-vline[data-major]{background:var(--nf-line)}
|
|
1294
|
+
.nf-t-today{position:absolute;top:0;bottom:0;width:2px;margin-left:-1px;background:var(--nf-now);z-index:2}
|
|
1295
|
+
.nf-t-lane{position:absolute;left:0;display:flex}
|
|
1296
|
+
.nf-t-lane[data-alt]{background:var(--nf-surface-alt)}
|
|
1297
|
+
.nf-t-lanehead{position:sticky;left:0;z-index:3;flex:none;width:var(--nf-lanehead);display:flex;align-items:flex-start;padding:10px 12px 0 8px;background:inherit;background-color:var(--nf-surface);border-right:1px solid var(--nf-line);border-bottom:1px solid var(--nf-line-soft)}
|
|
1298
|
+
.nf-t-lane[data-alt] .nf-t-lanehead{background-color:var(--nf-surface-alt)}
|
|
1299
|
+
.nf-t-lanebtn{display:inline-flex;align-items:center;gap:6px;max-width:100%;font:inherit;font-size:var(--nf-text-header);font-weight:600;color:var(--nf-ink);background:none;border:0;padding:4px;border-radius:6px;cursor:pointer;text-align:start}
|
|
1300
|
+
.nf-t-lanebtn span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1301
|
+
.nf-t-lanebtn svg{flex:none;transition:transform ${motion.fast} ${motion.ease}}
|
|
1302
|
+
.nf-t-lanebtn[aria-expanded="false"] svg{transform:rotate(-90deg)}
|
|
1303
|
+
.nf-tl[data-rtl] .nf-t-lanebtn[aria-expanded="false"] svg{transform:rotate(90deg)}
|
|
1304
|
+
.nf-t-lanecount{font-weight:400;color:var(--nf-muted)}
|
|
1305
|
+
.nf-t-track{position:relative;flex:none;border-bottom:1px solid var(--nf-line-soft)}
|
|
1306
|
+
.nf-t-bar{position:absolute;z-index:1;height:calc(var(--nf-row) - 8px);border-radius:6px;padding:0 8px;display:flex;align-items:center;font-size:var(--nf-text-value);font-weight:500;white-space:nowrap;overflow:hidden;cursor:pointer;background:var(--bar);color:var(--bar-text);border:1px solid var(--bar);user-select:none;touch-action:none}
|
|
1307
|
+
.nf-t-barname{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
1308
|
+
.nf-t-bar[data-style="outline"]{background:var(--nf-surface);color:var(--nf-ink);border:1.5px dashed var(--bar)}
|
|
1309
|
+
.nf-t-bar[data-style="done"]{background:color-mix(in srgb,var(--bar) 22%,var(--nf-surface));color:var(--nf-ink);border-color:color-mix(in srgb,var(--bar) 45%,var(--nf-surface))}
|
|
1310
|
+
.nf-t-bar[data-edit]{cursor:grab}
|
|
1311
|
+
.nf-tl.nf-t-dragging .nf-t-bar[data-edit]{cursor:grabbing}
|
|
1312
|
+
.nf-t-bar[aria-selected="true"]{box-shadow:0 0 0 2px var(--nf-surface),0 0 0 4px var(--nf-focus)}
|
|
1313
|
+
.nf-t-bar[data-preview]{opacity:.9;z-index:2}
|
|
1314
|
+
.nf-t-h{position:absolute;top:0;bottom:0;width:8px;cursor:ew-resize}
|
|
1315
|
+
.nf-t-h[data-edge="start"]{left:-1px}
|
|
1316
|
+
.nf-t-h[data-edge="end"]{right:-1px}
|
|
1317
|
+
.nf-t-ms{position:absolute;z-index:1;width:14px;height:14px;margin-top:2px;transform:rotate(45deg);border-radius:3px;background:var(--bar);border:1px solid var(--bar);cursor:pointer}
|
|
1318
|
+
.nf-t-ms[data-style="outline"]{background:var(--nf-surface);border:2px solid var(--bar)}
|
|
1319
|
+
.nf-t-ms[aria-selected="true"]{box-shadow:0 0 0 2px var(--nf-surface),0 0 0 4px var(--nf-focus)}
|
|
1320
|
+
.nf-t-label{position:absolute;z-index:1;font-size:var(--nf-text-value);font-weight:500;white-space:nowrap;color:var(--nf-ink);pointer-events:none;background:var(--nf-surface);padding:0 3px;margin-left:-3px;border-radius:3px}
|
|
1321
|
+
/* Connectors run under bars, diamonds and labels. */
|
|
1322
|
+
.nf-t-depwrap{position:absolute;top:0;z-index:0;pointer-events:none}
|
|
1323
|
+
.nf-t-deps{position:absolute;top:0;left:0;pointer-events:none;overflow:visible}
|
|
1324
|
+
.nf-t-deps path{fill:none;stroke:var(--nf-muted);stroke-width:1.25}
|
|
1325
|
+
.nf-t-draglabel{position:absolute;z-index:6;padding:4px 8px;border-radius:6px;background:var(--nf-tooltip-bg);color:var(--nf-tooltip-ink);font-size:var(--nf-text-value);white-space:nowrap;pointer-events:none;direction:ltr}
|
|
1326
|
+
.nf-t-empty{position:absolute;left:var(--nf-lanehead);right:0;top:40px;text-align:center;color:var(--nf-muted);font-size:var(--nf-text-body)}.nf-t-emptyaction{display:flex;flex-direction:column;align-items:center;gap:8px;margin-top:12px;color:var(--nf-muted);font-size:var(--nf-text-body)}.nf-t-emptyaction small{font-size:var(--nf-text-callout);max-width:360px}.nf-t-emptyaction [role="alert"]{color:var(--nf-negative,#D93F2B)}
|
|
1327
|
+
.nf-t-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}
|
|
1328
|
+
.nf-t-legend{display:flex;flex-wrap:wrap;gap:12px;padding:8px 16px;border-top:1px solid var(--nf-line);font-size:var(--nf-text-callout);color:var(--nf-muted)}
|
|
1329
|
+
.nf-t-legend span{display:inline-flex;align-items:center;gap:6px}
|
|
1330
|
+
.nf-t-legend i{display:inline-block;width:14px;height:10px;border-radius:3px;background:var(--sw);border:1px solid var(--sw)}
|
|
1331
|
+
.nf-t-legend i[data-style="outline"]{background:transparent;border-style:dashed}
|
|
1332
|
+
.nf-t-legend i[data-style="done"]{background:color-mix(in srgb,var(--sw) 22%,var(--nf-surface))}
|
|
1333
|
+
.nf-t-tray{flex:none;width:260px;display:flex;flex-direction:column;border-left:1px solid var(--nf-line);background:var(--nf-surface)}
|
|
1334
|
+
.nf-tl[data-narrow] .nf-t-tray{position:absolute;inset:0;z-index:6;width:auto;border-left:0}
|
|
1335
|
+
.nf-t-trayhead{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--nf-line);font-weight:600}
|
|
1336
|
+
.nf-t-traylist{flex:1;overflow:auto;padding:8px;display:flex;flex-direction:column;gap:6px;margin:0;list-style:none}
|
|
1337
|
+
.nf-t-trayitem{display:block;width:100%;text-align:start;font:inherit;font-size:var(--nf-text-body);padding:8px 10px;border-radius:9px;border:1px solid var(--nf-line);border-inline-start:3px solid var(--bar);background:var(--nf-surface);color:var(--nf-ink);cursor:pointer}
|
|
1338
|
+
.nf-t-trayitem small{display:block;margin-top:2px;font-size:var(--nf-text-callout);color:var(--nf-muted)}
|
|
1339
|
+
.nf-t-trayitem[aria-pressed="true"]{border-color:var(--nf-focus)}
|
|
1340
|
+
.nf-t-nnl{flex:1;min-width:0;overflow:auto}
|
|
1341
|
+
.nf-t-nnlgrid{display:grid;min-width:640px}
|
|
1342
|
+
.nf-t-nnlhead{position:sticky;top:0;z-index:2;padding:12px 12px 8px;background:var(--nf-surface);border-bottom:1px solid var(--nf-line);font-weight:600;font-size:var(--nf-text-header)}
|
|
1343
|
+
.nf-t-nnlhead .nf-t-lanecount{margin-inline-start:6px}
|
|
1344
|
+
.nf-t-nnllane{padding:10px 12px;font-size:var(--nf-text-header);font-weight:600;border-bottom:1px solid var(--nf-line-soft);background:var(--nf-surface)}
|
|
1345
|
+
.nf-t-nnlcell{padding:8px;display:flex;flex-direction:column;gap:6px;border-bottom:1px solid var(--nf-line-soft);border-inline-start:1px solid var(--nf-line-soft)}
|
|
1346
|
+
.nf-t-nnlgrid>[data-alt]{background:var(--nf-surface-alt)}
|
|
1347
|
+
.nf-t-card{display:block;width:100%;text-align:start;font:inherit;font-size:var(--nf-text-body);padding:8px 10px;border-radius:8px;border:1px solid var(--nf-line);border-inline-start:4px solid var(--bar);background:var(--nf-surface);color:var(--nf-ink);cursor:pointer}
|
|
1348
|
+
.nf-t-card small{display:block;margin-top:2px;font-size:var(--nf-text-callout);color:var(--nf-muted)}
|
|
1349
|
+
.nf-t-card[aria-pressed="true"]{border-color:var(--nf-focus)}
|
|
1350
|
+
.nf-t-menuwrap{position:relative;display:inline-flex}
|
|
1351
|
+
.nf-t-menu{position:absolute;top:calc(100% + 6px);right:0;z-index:9;min-width:180px;display:flex;flex-direction:column;padding:4px;border-radius:10px;border:1px solid var(--nf-line);background:var(--nf-surface);box-shadow:0 8px 24px rgba(14,17,22,.14)}
|
|
1352
|
+
.nf-t-menu [role="menuitem"]{font:inherit;font-size:var(--nf-text-body);text-align:start;color:var(--nf-ink);background:none;border:0;border-radius:7px;padding:8px 10px;cursor:pointer;unicode-bidi:plaintext}
|
|
1353
|
+
.nf-t-menu [role="menuitem"]:hover,.nf-t-menu [role="menuitem"]:focus-visible{background:var(--nf-line-soft);outline:none}
|
|
1354
|
+
.nf-t-menu [role="menuitem"]:focus-visible{box-shadow:inset 0 0 0 2px var(--nf-focus)}
|
|
1355
|
+
.nf-t-mark{position:absolute;right:12px;bottom:12px;z-index:7;display:inline-flex;align-items:center;gap:6px;padding:5px 9px 5px 6px;border-radius:8px;background:var(--nf-surface);border:1px solid var(--nf-line);box-shadow:0 2px 8px rgba(14,17,22,.08);font-size:var(--nf-text-caption);color:var(--nf-muted);text-decoration:none;font-family:inherit;cursor:pointer}
|
|
1356
|
+
.nf-t-mark:hover{color:var(--nf-ink)}
|
|
1357
|
+
.nf-t-locknote{position:absolute;left:12px;bottom:12px;z-index:7;margin:0;max-width:calc(100% - 220px);padding:5px 9px;border-radius:8px;background:var(--nf-surface);border:1px solid var(--nf-line);font-size:var(--nf-text-caption);color:var(--nf-muted)}
|
|
1358
|
+
.nf-t-renew{position:absolute;right:12px;bottom:12px;z-index:7;display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:8px;background:var(--nf-surface);border:1px solid var(--nf-line);box-shadow:0 2px 8px rgba(14,17,22,.08);font-size:var(--nf-text-caption);color:var(--nf-muted);text-decoration:none}
|
|
1359
|
+
.nf-t-renew:hover{color:var(--nf-ink)}
|
|
1360
|
+
.nf-t-renew:focus-visible{outline:2px solid var(--nf-focus);outline-offset:2px}
|
|
1361
|
+
.nf-t-renew::before{content:"";width:6px;height:6px;border-radius:3px;background:var(--nf-milestone)}
|
|
1362
|
+
.nf-t-mark:focus-visible{outline:2px solid var(--nf-focus);outline-offset:2px}
|
|
1363
|
+
.nf-t-toasts{position:absolute;left:50%;bottom:16px;z-index:8;transform:translateX(-50%);display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none}
|
|
1364
|
+
.nf-t-toast{pointer-events:auto;display:flex;align-items:center;gap:10px;padding:8px 8px 8px 12px;border-radius:10px;background:var(--nf-tooltip-bg);color:var(--nf-tooltip-ink);box-shadow:0 8px 24px rgba(14,17,22,.24);font-size:var(--nf-text-body);animation:nf-t-in ${motion.fast} ${motion.ease} both}
|
|
1365
|
+
.nf-t-toast[data-tone="error"]{background:var(--nf-negative);color:#fff}
|
|
1366
|
+
.nf-t-toast-btn{font:inherit;font-size:var(--nf-text-body);font-weight:600;color:inherit;background:rgba(255,255,255,.14);border:0;padding:5px 10px;border-radius:7px;cursor:pointer}
|
|
1367
|
+
.nf-t-toast-x{font:inherit;font-size:var(--nf-text-title);line-height:1;color:inherit;opacity:.7;background:none;border:0;padding:4px 6px;border-radius:6px;cursor:pointer}
|
|
1368
|
+
.nf-t-toast button:focus-visible{outline:2px solid var(--nf-focus);outline-offset:2px}
|
|
1369
|
+
.nf-tl.nf-t-settled .nf-t-bar,.nf-tl.nf-t-settled .nf-t-ms{animation:nf-t-in ${motion.base} ${motion.ease} both}
|
|
1370
|
+
@keyframes nf-t-in{from{opacity:0;transform:scaleX(.96)}to{opacity:1;transform:none}}
|
|
1371
|
+
@media (prefers-reduced-motion: reduce){.nf-tl *{animation:none!important;transition:none!important}}
|
|
1372
|
+
.nf-tl[data-static] *{animation:none!important;transition:none!important}
|
|
1373
|
+
.nf-t-bar[data-dim],.nf-t-ms[data-dim],.nf-t-label[data-dim]{opacity:${HOST_DIM}}
|
|
1374
|
+
@media (forced-colors: active){
|
|
1375
|
+
.nf-tl{border-color:CanvasText}
|
|
1376
|
+
.nf-t-bar,.nf-t-ms,.nf-t-today,.nf-t-count,.nf-t-legend i{forced-color-adjust:none}
|
|
1377
|
+
.nf-t-bar{background:Canvas;color:CanvasText;border:1px solid CanvasText}
|
|
1378
|
+
.nf-t-bar[data-style="outline"]{border-style:dashed}
|
|
1379
|
+
.nf-t-bar[data-style="done"]{border-style:dotted}
|
|
1380
|
+
.nf-t-ms{background:CanvasText;border-color:CanvasText}
|
|
1381
|
+
.nf-t-bar[aria-selected="true"],.nf-t-ms[aria-selected="true"]{box-shadow:none;outline:2px solid Highlight;outline-offset:2px}
|
|
1382
|
+
.nf-t-today{background:Highlight}
|
|
1383
|
+
.nf-t-count{background:Canvas;color:CanvasText;border:1px solid CanvasText}
|
|
1384
|
+
.nf-t-legend i{background:Canvas;border-color:CanvasText}
|
|
1385
|
+
.nf-t-seg .nf-t-btn[aria-pressed="true"]{outline:2px solid Highlight;outline-offset:-2px}
|
|
1386
|
+
.nf-t-card,.nf-t-trayitem,.nf-t-menu{border-color:CanvasText}
|
|
1387
|
+
.nf-t-card[aria-pressed="true"],.nf-t-trayitem[aria-pressed="true"]{outline:2px solid Highlight}
|
|
1388
|
+
.nf-t-toast{forced-color-adjust:none;background:Canvas;color:CanvasText;border:1px solid CanvasText}
|
|
1389
|
+
.nf-tl :focus-visible{outline:2px solid Highlight}
|
|
1390
|
+
}
|
|
1391
|
+
`;
|
|
1392
|
+
|
|
1393
|
+
// src/export.ts
|
|
1394
|
+
var PRINT_TIMELINE_W = 840;
|
|
1395
|
+
var PRINT_LANE_W = 180;
|
|
1396
|
+
var PRINT_ROW = 22;
|
|
1397
|
+
var e = escapeHtml;
|
|
1398
|
+
function fitScale(s, width) {
|
|
1399
|
+
const k = width / s.width;
|
|
1400
|
+
return {
|
|
1401
|
+
x: (day) => Math.round(s.x(day) * k * 10) / 10,
|
|
1402
|
+
dayWidth: s.dayWidth * k,
|
|
1403
|
+
width,
|
|
1404
|
+
major: s.major.map((t) => ({ x: t.x * k, width: t.width * k, label: t.label })),
|
|
1405
|
+
minor: s.minor.map((t) => ({ x: t.x * k, width: t.width * k, label: t.label })),
|
|
1406
|
+
startDay: s.startDay,
|
|
1407
|
+
endDay: s.endDay
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
var fallback = (t) => ({ color: t.accent, text: t.accentText, style: "solid" });
|
|
1411
|
+
function laneSvg(lane, f, o) {
|
|
1412
|
+
const t = o.theme;
|
|
1413
|
+
const { row, rows } = packRows(lane.items.map((p) => footprint(p, f)));
|
|
1414
|
+
const h = Math.max(28, rows * PRINT_ROW + 8);
|
|
1415
|
+
const out = [];
|
|
1416
|
+
for (const m of f.minor) out.push(`<line x1="${m.x}" x2="${m.x}" y1="0" y2="${h}" stroke="${t.lineSoft}"/>`);
|
|
1417
|
+
if (o.today >= f.startDay && o.today < f.endDay) {
|
|
1418
|
+
const tx = f.x(o.today) + f.dayWidth / 2;
|
|
1419
|
+
out.push(`<line x1="${tx}" x2="${tx}" y1="0" y2="${h}" stroke="${brand.ember}" stroke-width="1.5"/>`);
|
|
1420
|
+
}
|
|
1421
|
+
for (const p of lane.items) {
|
|
1422
|
+
const look = o.looks.get(p.id) ?? fallback(t);
|
|
1423
|
+
const y = 4 + (row.get(p.id) ?? 0) * PRINT_ROW;
|
|
1424
|
+
if (p.milestone) {
|
|
1425
|
+
const cx = f.x(p.start) + f.dayWidth / 2;
|
|
1426
|
+
out.push(`<rect x="${cx - 5}" y="${y + 4}" width="10" height="10" rx="2" fill="${look.style === "outline" ? "#fff" : look.color}" stroke="${look.color}" stroke-width="1.5" transform="rotate(45 ${cx} ${y + 9})"/>`);
|
|
1427
|
+
out.push(`<text x="${cx + 9}" y="${y + 12.5}" fill="${t.ink}">${e(p.item.name)}</text>`);
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
const x0 = f.x(p.start);
|
|
1431
|
+
const w = Math.max(3, f.x(p.end + 1) - x0);
|
|
1432
|
+
const attrs = look.style === "outline" ? `fill="#fff" stroke="${look.color}" stroke-dasharray="3 2"` : look.style === "done" ? `fill="${look.color}" fill-opacity=".25" stroke="${look.color}" stroke-opacity=".5"` : `fill="${look.color}"`;
|
|
1433
|
+
out.push(`<rect x="${x0}" y="${y}" width="${w}" height="17" rx="4" ${attrs}/>`);
|
|
1434
|
+
const inside = w >= LABEL_INSIDE_MIN;
|
|
1435
|
+
const ink = look.style === "solid" ? look.text : t.ink;
|
|
1436
|
+
const maxChars = Math.floor((w - 8) / 5.4);
|
|
1437
|
+
const name = inside ? p.item.name.length <= maxChars ? p.item.name : `${p.item.name.slice(0, Math.max(1, maxChars - 1))}\u2026` : p.item.name;
|
|
1438
|
+
out.push(`<text x="${inside ? x0 + 5 : x0 + w + 5}" y="${y + 12}" fill="${inside ? ink : t.ink}">${e(name)}</text>`);
|
|
1439
|
+
}
|
|
1440
|
+
return `<svg width="${f.width}" height="${h}" viewBox="0 0 ${f.width} ${h}" aria-hidden="true">${out.join("")}</svg>`;
|
|
1441
|
+
}
|
|
1442
|
+
function legendHtml(o) {
|
|
1443
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1444
|
+
for (const l of o.lanes) for (const p of l.items) {
|
|
1445
|
+
const key = (p.item.status ?? "").trim();
|
|
1446
|
+
if (key && !seen.has(key)) seen.set(key, o.looks.get(p.id) ?? fallback(o.theme));
|
|
1447
|
+
}
|
|
1448
|
+
const sw = (a) => a.style === "outline" ? `border:1.5px dashed ${a.color};background:#fff` : a.style === "done" ? `background:${a.color};opacity:.35` : `background:${a.color}`;
|
|
1449
|
+
return [
|
|
1450
|
+
...[...seen.entries()].map(([k, a]) => `<span><i style="${sw(a)}"></i>${e(k)}</span>`),
|
|
1451
|
+
`<span><i style="background:${o.theme.ink};width:8px;height:8px;transform:rotate(45deg)"></i>Milestone</span>`,
|
|
1452
|
+
`<span><i style="background:${brand.ember};width:2px;height:11px"></i>Today</span>`
|
|
1453
|
+
].join("");
|
|
1454
|
+
}
|
|
1455
|
+
function shell(title, meta, legend, t, body) {
|
|
1456
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${e(title)}</title><style>
|
|
1457
|
+
@page{size:landscape;margin:10mm}
|
|
1458
|
+
*{box-sizing:border-box}
|
|
1459
|
+
html,body{margin:0;background:#fff;color:${t.ink};font-family:Geist,system-ui,-apple-system,"Segoe UI",sans-serif;font-size:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
|
1460
|
+
header{display:flex;align-items:baseline;flex-wrap:wrap;gap:12px;margin-bottom:8px}
|
|
1461
|
+
h1{font-size:16px;margin:0;letter-spacing:-0.01em}
|
|
1462
|
+
h2{font-size:12px;margin:14px 0 6px}
|
|
1463
|
+
.meta{color:${t.muted}}
|
|
1464
|
+
.legend{display:flex;flex-wrap:wrap;gap:12px;margin-left:auto;color:${t.muted}}
|
|
1465
|
+
.legend span{display:inline-flex;align-items:center;gap:5px}
|
|
1466
|
+
.legend i{display:inline-block;width:16px;height:9px;border-radius:2px}
|
|
1467
|
+
table{width:100%;border-collapse:collapse;table-layout:fixed}
|
|
1468
|
+
thead{display:table-header-group}
|
|
1469
|
+
tr{page-break-inside:avoid;break-inside:avoid}
|
|
1470
|
+
th{font-weight:600;text-align:left;padding:0 6px 4px;border-bottom:1px solid ${t.line};vertical-align:bottom}
|
|
1471
|
+
th.tl{padding:0}
|
|
1472
|
+
td{padding:4px 6px;border-bottom:1px solid ${t.lineSoft};vertical-align:top}
|
|
1473
|
+
td.tl{padding:0}
|
|
1474
|
+
tr.alt td{background:${t.surfaceAlt}}
|
|
1475
|
+
td.lane{font-weight:600}
|
|
1476
|
+
svg text{font-family:inherit;font-size:9px}
|
|
1477
|
+
.muted{color:${t.muted}}
|
|
1478
|
+
.card{border:1px solid ${t.line};border-left:3px solid var(--c);border-radius:4px;padding:3px 6px;margin-bottom:4px}
|
|
1479
|
+
.card small{display:block;color:${t.muted}}
|
|
1480
|
+
</style></head><body>
|
|
1481
|
+
<header><h1>${e(title)}</h1><span class="meta">${meta}</span><div class="legend">${legend}</div></header>
|
|
1482
|
+
${body}
|
|
1483
|
+
</body></html>`;
|
|
1484
|
+
}
|
|
1485
|
+
function trayTable(o) {
|
|
1486
|
+
if (!o.unscheduled.length) return "";
|
|
1487
|
+
return `<h2>Not scheduled (${o.unscheduled.length})</h2><table><colgroup><col><col style="width:160px"><col style="width:120px"><col style="width:90px"></colgroup><thead><tr><th>Item</th><th>Lane</th><th>Status</th><th>Horizon</th></tr></thead><tbody>${o.unscheduled.map((i) => `<tr><td>${e(i.name)}</td><td>${e(i.lane || NO_LANE)}</td><td class="muted">${e(i.status ?? "")}</td><td class="muted">${e(i.horizon ?? "")}</td></tr>`).join("")}</tbody></table>`;
|
|
1488
|
+
}
|
|
1489
|
+
function timelinePrintHtml(o) {
|
|
1490
|
+
const t = o.theme;
|
|
1491
|
+
const count = o.lanes.reduce((n, l) => n + l.items.length, 0) + o.unscheduled.length;
|
|
1492
|
+
if (o.mode === "horizon") {
|
|
1493
|
+
const all = [...o.lanes.flatMap((l) => l.items.map((p) => p.item)), ...o.unscheduled];
|
|
1494
|
+
const cols = horizonColumns(all);
|
|
1495
|
+
const lanes = [...new Set(all.map((i) => (i.lane ?? "").trim() || NO_LANE))];
|
|
1496
|
+
const body2 = `<table><thead><tr><th style="width:150px">Lane</th>${cols.map((c) => `<th>${e(c.key)} <span class="muted">${c.items.length}</span></th>`).join("")}</tr></thead><tbody>${lanes.map(
|
|
1497
|
+
(lane, i) => `<tr${i % 2 ? ' class="alt"' : ""}><td class="lane">${e(lane)}</td>${cols.map(
|
|
1498
|
+
(c) => `<td>${c.items.filter((it) => ((it.lane ?? "").trim() || NO_LANE) === lane).map((it) => {
|
|
1499
|
+
const p = place(it);
|
|
1500
|
+
const when = p ? p.milestone ? dayLabel(p.start) : rangeLabel(p.start, p.end) : "Not scheduled";
|
|
1501
|
+
return `<div class="card" style="--c:${(o.looks.get(it.id) ?? fallback(t)).color}">${e(it.name)}<small>${e([it.status, when].filter(Boolean).join(" \xB7 "))}</small></div>`;
|
|
1502
|
+
}).join("")}</td>`
|
|
1503
|
+
).join("")}</tr>`
|
|
1504
|
+
).join("")}</tbody></table>`;
|
|
1505
|
+
return shell(o.title, `Now, next and later \xB7 ${count} items \xB7 Printed on ${e(o.printedOn)}`, legendHtml(o), t, body2);
|
|
1506
|
+
}
|
|
1507
|
+
const f = fitScale(o.scale, PRINT_TIMELINE_W);
|
|
1508
|
+
const head = `<svg width="${f.width}" height="34" viewBox="0 0 ${f.width} 34" aria-hidden="true">${f.major.map((m) => `<line x1="${m.x}" x2="${m.x}" y1="0" y2="34" stroke="${t.line}"/><text x="${m.x + 4}" y="11" font-weight="600" fill="${t.ink}">${e(m.label)}</text>`).join("")}${f.minor.map((m) => `<line x1="${m.x}" x2="${m.x}" y1="16" y2="34" stroke="${t.lineSoft}"/><text x="${m.x + 4}" y="28" fill="${t.muted}">${e(m.label)}</text>`).join("")}</svg>`;
|
|
1509
|
+
const rows = o.lanes.map((l, i) => `<tr${i % 2 ? ' class="alt"' : ""}><td class="lane">${e(l.label)} <span class="muted">${l.items.length}</span></td><td class="tl">${laneSvg(l, f, o)}</td></tr>`).join("");
|
|
1510
|
+
const range = `${o.scale.minor[0]?.label ?? ""} \u2013 ${o.scale.minor[o.scale.minor.length - 1]?.label ?? ""}`;
|
|
1511
|
+
const body = `<table><colgroup><col style="width:${PRINT_LANE_W}px"><col style="width:${f.width}px"></colgroup><thead><tr><th>Lane</th><th class="tl">${head}</th></tr></thead><tbody>${rows}</tbody></table>${trayTable(o)}`;
|
|
1512
|
+
return shell(o.title, `${e(range)} \xB7 ${count} items \xB7 Printed on ${e(o.printedOn)}`, legendHtml(o), t, body);
|
|
1513
|
+
}
|
|
1514
|
+
var TITLE_H = 48;
|
|
1515
|
+
var LEGEND_H = 36;
|
|
1516
|
+
var FONT = 'Geist, system-ui, -apple-system, "Segoe UI", sans-serif';
|
|
1517
|
+
function timelinePngSize(o) {
|
|
1518
|
+
const body = o.lanes.reduce((h, l) => h + l.height, 0) || 60;
|
|
1519
|
+
const width = Math.ceil(LANE_HEAD_W + o.scale.width);
|
|
1520
|
+
const height = Math.ceil(TITLE_H + HEAD_H + body + LEGEND_H);
|
|
1521
|
+
const ratio2 = exportRatio(width, height);
|
|
1522
|
+
return ratio2 === null ? null : { width, height, ratio: ratio2 };
|
|
1523
|
+
}
|
|
1524
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
1525
|
+
const rr = Math.min(r, w / 2, h / 2);
|
|
1526
|
+
ctx.beginPath();
|
|
1527
|
+
ctx.moveTo(x + rr, y);
|
|
1528
|
+
ctx.arcTo(x + w, y, x + w, y + h, rr);
|
|
1529
|
+
ctx.arcTo(x + w, y + h, x, y + h, rr);
|
|
1530
|
+
ctx.arcTo(x, y + h, x, y, rr);
|
|
1531
|
+
ctx.arcTo(x, y, x + w, y, rr);
|
|
1532
|
+
ctx.closePath();
|
|
1533
|
+
}
|
|
1534
|
+
function drawTimeline(ctx, size, o) {
|
|
1535
|
+
const c = (v, fb) => canvasColor(ctx, v, fb);
|
|
1536
|
+
const T = { surface: c(o.theme.surface, "#fff"), alt: c(o.theme.surfaceAlt, "#f6f6f3"), ink: c(o.theme.ink, "#111"), muted: c(o.theme.muted, "#666"), line: c(o.theme.line, "#ddd"), soft: c(o.theme.lineSoft, "#eee") };
|
|
1537
|
+
const s = o.scale;
|
|
1538
|
+
const X0 = LANE_HEAD_W;
|
|
1539
|
+
const Y0 = TITLE_H + HEAD_H;
|
|
1540
|
+
ctx.save();
|
|
1541
|
+
ctx.scale(size.ratio, size.ratio);
|
|
1542
|
+
ctx.fillStyle = T.surface;
|
|
1543
|
+
ctx.fillRect(0, 0, size.width, size.height);
|
|
1544
|
+
ctx.textBaseline = "middle";
|
|
1545
|
+
ctx.fillStyle = T.ink;
|
|
1546
|
+
ctx.font = `600 16px ${FONT}`;
|
|
1547
|
+
ctx.fillText(o.title, 16, 24);
|
|
1548
|
+
for (const m of s.major) {
|
|
1549
|
+
ctx.fillStyle = T.line;
|
|
1550
|
+
ctx.fillRect(X0 + m.x, TITLE_H, 1, size.height - TITLE_H - LEGEND_H);
|
|
1551
|
+
ctx.fillStyle = T.ink;
|
|
1552
|
+
ctx.font = `600 12px ${FONT}`;
|
|
1553
|
+
ctx.fillText(m.label, X0 + m.x + 8, TITLE_H + 13);
|
|
1554
|
+
}
|
|
1555
|
+
for (const m of s.minor) {
|
|
1556
|
+
ctx.fillStyle = T.soft;
|
|
1557
|
+
ctx.fillRect(X0 + m.x, TITLE_H + 26, 1, size.height - TITLE_H - 26 - LEGEND_H);
|
|
1558
|
+
ctx.fillStyle = T.muted;
|
|
1559
|
+
ctx.font = `400 11px ${FONT}`;
|
|
1560
|
+
ctx.fillText(m.label, X0 + m.x + 8, TITLE_H + 39);
|
|
1561
|
+
}
|
|
1562
|
+
ctx.fillStyle = T.line;
|
|
1563
|
+
ctx.fillRect(0, Y0 - 1, size.width, 1);
|
|
1564
|
+
for (const l of o.lanes) {
|
|
1565
|
+
const y = Y0 + l.top;
|
|
1566
|
+
if (l.index % 2) {
|
|
1567
|
+
ctx.fillStyle = T.alt;
|
|
1568
|
+
ctx.fillRect(0, y, size.width, l.height);
|
|
1569
|
+
}
|
|
1570
|
+
ctx.fillStyle = T.soft;
|
|
1571
|
+
ctx.fillRect(0, y + l.height - 1, size.width, 1);
|
|
1572
|
+
ctx.fillStyle = T.ink;
|
|
1573
|
+
ctx.font = `600 13px ${FONT}`;
|
|
1574
|
+
ctx.fillText(`${l.label} ${l.items.length}`, 16, y + 20, LANE_HEAD_W - 28);
|
|
1575
|
+
if (l.collapsed) continue;
|
|
1576
|
+
for (const p of l.items) drawItem(ctx, p, l, o, T, X0, y);
|
|
1577
|
+
}
|
|
1578
|
+
ctx.fillStyle = T.line;
|
|
1579
|
+
ctx.fillRect(LANE_HEAD_W - 1, TITLE_H, 1, size.height - TITLE_H - LEGEND_H);
|
|
1580
|
+
const legendY = size.height - LEGEND_H;
|
|
1581
|
+
if (o.today >= s.startDay && o.today < s.endDay) {
|
|
1582
|
+
ctx.fillStyle = brand.ember;
|
|
1583
|
+
ctx.fillRect(X0 + s.x(o.today) + s.dayWidth / 2 - 1, TITLE_H, 2, legendY - TITLE_H);
|
|
1584
|
+
}
|
|
1585
|
+
drawLegend(ctx, o, T, legendY, size.width);
|
|
1586
|
+
ctx.restore();
|
|
1587
|
+
}
|
|
1588
|
+
function drawLegend(ctx, o, T, y, width) {
|
|
1589
|
+
ctx.fillStyle = T.surface;
|
|
1590
|
+
ctx.fillRect(0, y, width, LEGEND_H);
|
|
1591
|
+
ctx.fillStyle = T.line;
|
|
1592
|
+
ctx.fillRect(0, y, width, 1);
|
|
1593
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1594
|
+
for (const l of o.lanes) for (const p of l.items) {
|
|
1595
|
+
const key = (p.item.status ?? "").trim();
|
|
1596
|
+
if (key && !seen.has(key)) seen.set(key, o.looks.get(p.id) ?? fallback(o.theme));
|
|
1597
|
+
}
|
|
1598
|
+
ctx.font = `400 12px ${FONT}`;
|
|
1599
|
+
ctx.textBaseline = "middle";
|
|
1600
|
+
let x = 16;
|
|
1601
|
+
const cy = y + LEGEND_H / 2;
|
|
1602
|
+
for (const [label, look] of seen) {
|
|
1603
|
+
const color = canvasColor(ctx, look.color, "#2E54FF");
|
|
1604
|
+
roundRect(ctx, x, cy - 5, 16, 10, 3);
|
|
1605
|
+
if (look.style === "outline") {
|
|
1606
|
+
ctx.setLineDash([3, 2]);
|
|
1607
|
+
ctx.strokeStyle = color;
|
|
1608
|
+
ctx.stroke();
|
|
1609
|
+
ctx.setLineDash([]);
|
|
1610
|
+
} else {
|
|
1611
|
+
ctx.save();
|
|
1612
|
+
if (look.style === "done") ctx.globalAlpha = 0.3;
|
|
1613
|
+
ctx.fillStyle = color;
|
|
1614
|
+
ctx.fill();
|
|
1615
|
+
ctx.restore();
|
|
1616
|
+
}
|
|
1617
|
+
ctx.fillStyle = T.muted;
|
|
1618
|
+
ctx.fillText(label, x + 22, cy);
|
|
1619
|
+
x += 22 + ctx.measureText(label).width + 18;
|
|
1620
|
+
}
|
|
1621
|
+
ctx.save();
|
|
1622
|
+
ctx.translate(x + 5, cy);
|
|
1623
|
+
ctx.rotate(Math.PI / 4);
|
|
1624
|
+
ctx.fillStyle = T.ink;
|
|
1625
|
+
ctx.fillRect(-4, -4, 8, 8);
|
|
1626
|
+
ctx.restore();
|
|
1627
|
+
ctx.fillStyle = T.muted;
|
|
1628
|
+
ctx.fillText("Milestone", x + 16, cy);
|
|
1629
|
+
x += 16 + ctx.measureText("Milestone").width + 18;
|
|
1630
|
+
ctx.fillStyle = brand.ember;
|
|
1631
|
+
ctx.fillRect(x, cy - 6, 2, 12);
|
|
1632
|
+
ctx.fillStyle = T.muted;
|
|
1633
|
+
ctx.fillText("Today", x + 8, cy);
|
|
1634
|
+
}
|
|
1635
|
+
function drawItem(ctx, p, l, o, T, X0, laneY) {
|
|
1636
|
+
const s = o.scale;
|
|
1637
|
+
const look = o.looks.get(p.id) ?? fallback(o.theme);
|
|
1638
|
+
const color = canvasColor(ctx, look.color, "#2E54FF");
|
|
1639
|
+
const y = laneY + LANE_PAD + (l.row.get(p.id) ?? 0) * ROW_H;
|
|
1640
|
+
ctx.font = `500 12px ${FONT}`;
|
|
1641
|
+
if (p.milestone) {
|
|
1642
|
+
const cx = X0 + s.x(p.start) + s.dayWidth / 2;
|
|
1643
|
+
ctx.save();
|
|
1644
|
+
ctx.translate(cx, y + 13);
|
|
1645
|
+
ctx.rotate(Math.PI / 4);
|
|
1646
|
+
ctx.fillStyle = look.style === "outline" ? T.surface : color;
|
|
1647
|
+
ctx.fillRect(-6, -6, 12, 12);
|
|
1648
|
+
ctx.strokeStyle = color;
|
|
1649
|
+
ctx.lineWidth = 2;
|
|
1650
|
+
ctx.strokeRect(-6, -6, 12, 12);
|
|
1651
|
+
ctx.restore();
|
|
1652
|
+
ctx.fillStyle = T.ink;
|
|
1653
|
+
ctx.fillText(p.item.name, cx + 12, y + 13.5);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
const x0 = X0 + s.x(p.start);
|
|
1657
|
+
const w = Math.max(6, s.x(p.end + 1) - s.x(p.start));
|
|
1658
|
+
roundRect(ctx, x0, y + 4, w, ROW_H - 8, 6);
|
|
1659
|
+
if (look.style === "outline") {
|
|
1660
|
+
ctx.fillStyle = T.surface;
|
|
1661
|
+
ctx.fill();
|
|
1662
|
+
ctx.setLineDash([4, 3]);
|
|
1663
|
+
ctx.strokeStyle = color;
|
|
1664
|
+
ctx.lineWidth = 1.5;
|
|
1665
|
+
ctx.stroke();
|
|
1666
|
+
ctx.setLineDash([]);
|
|
1667
|
+
} else if (look.style === "done") {
|
|
1668
|
+
ctx.save();
|
|
1669
|
+
ctx.globalAlpha = 0.25;
|
|
1670
|
+
ctx.fillStyle = color;
|
|
1671
|
+
ctx.fill();
|
|
1672
|
+
ctx.restore();
|
|
1673
|
+
} else {
|
|
1674
|
+
ctx.fillStyle = color;
|
|
1675
|
+
ctx.fill();
|
|
1676
|
+
}
|
|
1677
|
+
const inside = w >= LABEL_INSIDE_MIN;
|
|
1678
|
+
ctx.save();
|
|
1679
|
+
if (inside) {
|
|
1680
|
+
ctx.beginPath();
|
|
1681
|
+
ctx.rect(x0 + 6, y, w - 10, ROW_H);
|
|
1682
|
+
ctx.clip();
|
|
1683
|
+
}
|
|
1684
|
+
ctx.fillStyle = inside && look.style === "solid" ? canvasColor(ctx, look.text, "#fff") : T.ink;
|
|
1685
|
+
ctx.fillText(p.item.name, inside ? x0 + 8 : x0 + w + 6, y + 15.5, inside ? void 0 : labelWidth(p.item.name) + 40);
|
|
1686
|
+
ctx.restore();
|
|
1687
|
+
}
|
|
1688
|
+
function exportTimelinePng(o, fileName, doc = document) {
|
|
1689
|
+
const size = timelinePngSize(o);
|
|
1690
|
+
if (!size) return false;
|
|
1691
|
+
const canvas = doc.createElement("canvas");
|
|
1692
|
+
canvas.width = Math.round(size.width * size.ratio);
|
|
1693
|
+
canvas.height = Math.round(size.height * size.ratio);
|
|
1694
|
+
const ctx = canvas.getContext("2d");
|
|
1695
|
+
if (!ctx) return false;
|
|
1696
|
+
drawTimeline(ctx, size, o);
|
|
1697
|
+
downloadCanvas(canvas, fileName, doc);
|
|
1698
|
+
return true;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// src/theme.ts
|
|
1702
|
+
function resolveTheme(theme, accent, colors) {
|
|
1703
|
+
const base = theme === "dark" ? darkTheme : lightTheme;
|
|
1704
|
+
const overrides = { ...typeof theme === "object" ? theme : {}, ...hostColors(colors) };
|
|
1705
|
+
const ownText = overrides.accentText;
|
|
1706
|
+
if (accent) {
|
|
1707
|
+
overrides.accent = accent;
|
|
1708
|
+
overrides.focus = overrides.focus ?? accent;
|
|
1709
|
+
const surface = overrides.surface ?? base.surface;
|
|
1710
|
+
const dark = theme === "dark";
|
|
1711
|
+
overrides.accentTint = overrides.accentTint ?? `color-mix(in srgb, ${accent} ${dark ? 32 : 20}%, ${surface})`;
|
|
1712
|
+
overrides.selected = overrides.selected ?? `color-mix(in srgb, ${accent} ${dark ? 14 : 6}%, ${surface})`;
|
|
1713
|
+
}
|
|
1714
|
+
const finalAccent = overrides.accent ?? base.accent;
|
|
1715
|
+
const pair = ownText ? { background: finalAccent, text: ownText } : accentForText(finalAccent);
|
|
1716
|
+
overrides.accentText = pair.text;
|
|
1717
|
+
const vars = { ...themeVars(overrides, base), "--nf-accent-solid": pair.background };
|
|
1718
|
+
if (colors?.today) vars["--nf-now"] = colors.today;
|
|
1719
|
+
else if (colors?.foreground) vars["--nf-now"] = colors.foreground;
|
|
1720
|
+
return vars;
|
|
1721
|
+
}
|
|
1722
|
+
function hostColors(c) {
|
|
1723
|
+
const o = {};
|
|
1724
|
+
if (c?.background) o.surface = o.surfaceAlt = o.selected = o.lineSoft = o.tooltipInk = c.background;
|
|
1725
|
+
if (c?.foreground) o.ink = o.muted = o.line = o.tooltipBg = o.focus = c.foreground;
|
|
1726
|
+
return o;
|
|
1727
|
+
}
|
|
1728
|
+
function accentOf(theme, accent) {
|
|
1729
|
+
if (accent) return accent;
|
|
1730
|
+
if (typeof theme === "object" && theme.accent) return theme.accent;
|
|
1731
|
+
return (theme === "dark" ? darkTheme : lightTheme).accent;
|
|
1732
|
+
}
|
|
1733
|
+
function palette(theme, accent, forceLight) {
|
|
1734
|
+
const base = theme === "dark" && !forceLight ? darkTheme : lightTheme;
|
|
1735
|
+
const own = typeof theme === "object" && !forceLight ? theme : {};
|
|
1736
|
+
return { ...base, ...own, ...accent ? { accent, accentText: own.accentText ?? accentForText(accent).text } : {} };
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// src/useLicense.ts
|
|
1740
|
+
import { useEffect as useEffect2, useState as useState3 } from "react";
|
|
1741
|
+
function licenseOptions(opts) {
|
|
1742
|
+
return { product: opts.product, tenantId: opts.tenantId, host: browserHost() };
|
|
1743
|
+
}
|
|
1744
|
+
function useLicense(key, opts) {
|
|
1745
|
+
const [result, setResult] = useState3(null);
|
|
1746
|
+
const { product, tenantId } = opts;
|
|
1747
|
+
useEffect2(() => {
|
|
1748
|
+
let live = true;
|
|
1749
|
+
Promise.resolve(key).catch(() => void 0).then((k) => verifyLicenses(k, licenseOptions({ product, tenantId }))).then((r) => {
|
|
1750
|
+
if (live) setResult(r);
|
|
1751
|
+
});
|
|
1752
|
+
return () => {
|
|
1753
|
+
live = false;
|
|
1754
|
+
};
|
|
1755
|
+
}, [key, product, tenantId]);
|
|
1756
|
+
return result;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// src/Timeline.tsx
|
|
1760
|
+
var ZOOMS = [
|
|
1761
|
+
{ id: "month", label: "Month" },
|
|
1762
|
+
{ id: "quarter", label: "Quarter" },
|
|
1763
|
+
{ id: "half", label: "Half-year" }
|
|
1764
|
+
];
|
|
1765
|
+
var MODES = [
|
|
1766
|
+
{ id: "dates", label: "Dates" },
|
|
1767
|
+
{ id: "horizon", label: "Now \xB7 Next \xB7 Later" }
|
|
1768
|
+
];
|
|
1769
|
+
var DRAG_THRESHOLD = 4;
|
|
1770
|
+
var SETTLE_MS = 700;
|
|
1771
|
+
var Chevron = () => /* @__PURE__ */ React5.createElement("svg", { width: "12", height: "12", viewBox: "0 0 12 12", "aria-hidden": "true" }, /* @__PURE__ */ React5.createElement("path", { d: "M3 4.5 L6 7.5 L9 4.5", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round" }));
|
|
1772
|
+
function monthLabel(label, width, px) {
|
|
1773
|
+
const fits = (n) => n * px * 0.62 + 10 <= width;
|
|
1774
|
+
if (fits(label.length)) return label;
|
|
1775
|
+
return fits(1) ? label.slice(0, 1) : "";
|
|
1776
|
+
}
|
|
1777
|
+
function Timeline(props) {
|
|
1778
|
+
const title = props.title ?? "Roadmap";
|
|
1779
|
+
const accent = accentOf(props.theme, props.accent);
|
|
1780
|
+
const license = useLicense(props.licenseKey, { product: "plan", tenantId: props.tenantId });
|
|
1781
|
+
const renewal = props.renewalNotice !== false ? renewalNotice(license) : null;
|
|
1782
|
+
const todayDay = useMemo2(() => {
|
|
1783
|
+
const d = dayOfIso(props.today);
|
|
1784
|
+
return Number.isNaN(d) ? today() : d;
|
|
1785
|
+
}, [props.today]);
|
|
1786
|
+
const [zoomState, setZoomState] = useState4(props.defaultZoom ?? "quarter");
|
|
1787
|
+
const zoom = props.zoom ?? zoomState;
|
|
1788
|
+
const setZoom = (z) => {
|
|
1789
|
+
if (props.zoom === void 0) setZoomState(z);
|
|
1790
|
+
props.onZoomChange?.(z);
|
|
1791
|
+
};
|
|
1792
|
+
const [modeState, setModeState] = useState4(props.defaultMode ?? "dates");
|
|
1793
|
+
const mode = props.mode ?? modeState;
|
|
1794
|
+
const setMode = (m) => {
|
|
1795
|
+
if (props.mode === void 0) setModeState(m);
|
|
1796
|
+
props.onModeChange?.(m);
|
|
1797
|
+
};
|
|
1798
|
+
const [filter, setFilter] = useState4("");
|
|
1799
|
+
const [collapsed, setCollapsed] = useState4(() => /* @__PURE__ */ new Set());
|
|
1800
|
+
const [trayOpen, setTrayOpen] = useState4(false);
|
|
1801
|
+
const [selected, setSelectedState] = useState4(null);
|
|
1802
|
+
const setSelected = (id, multi = false) => {
|
|
1803
|
+
setSelectedState(id);
|
|
1804
|
+
props.onSelect?.(id, { multi });
|
|
1805
|
+
};
|
|
1806
|
+
const hl = props.highlighted && props.highlighted.length ? new Set(props.highlighted) : void 0;
|
|
1807
|
+
const hovering = useRef3(false);
|
|
1808
|
+
const hostHover = (item, at) => {
|
|
1809
|
+
if (!props.onHover) return;
|
|
1810
|
+
if (item === null && !hovering.current) return;
|
|
1811
|
+
hovering.current = item !== null;
|
|
1812
|
+
props.onHover(item, at);
|
|
1813
|
+
};
|
|
1814
|
+
const can = (action, item) => canEdit(props.editable, !!props.onItemChange, action, item);
|
|
1815
|
+
const placedRef = useRef3(/* @__PURE__ */ new Map());
|
|
1816
|
+
const spec = {
|
|
1817
|
+
...TIMELINE_FIELDS,
|
|
1818
|
+
// View-only without a key that unlocks Pro actions (Matt, 2026-11-08). Quiet while checking.
|
|
1819
|
+
...lockFor(license, unlocksPro(license), "Plan", "timeline"),
|
|
1820
|
+
noun: "item",
|
|
1821
|
+
describe: (change, nameOf) => describeChange(change, nameOf(change.id), placedRef.current.get(change.id))
|
|
1822
|
+
};
|
|
1823
|
+
const items = useMemo2(() => cleanItems(props.items), [props.items]);
|
|
1824
|
+
const editing = useEditing(items, props.onItemChange, spec);
|
|
1825
|
+
const announce = editing.announce;
|
|
1826
|
+
const baseRef = useRef3(editing.shown);
|
|
1827
|
+
baseRef.current = editing.shown;
|
|
1828
|
+
const uid = useState4(() => nextUid("nft"))[0];
|
|
1829
|
+
const textScale = clampTextScale(props.textScale ?? 1);
|
|
1830
|
+
const rowH = scaleWithText(ROW_H, textScale);
|
|
1831
|
+
const laneHeadW = Math.round(LANE_HEAD_W * textScale);
|
|
1832
|
+
const labelLineH = Math.ceil(textSize("value", textScale, props.textSizes) * 1.34);
|
|
1833
|
+
const rootRef = useRef3(null);
|
|
1834
|
+
const scrollRef = useRef3(null);
|
|
1835
|
+
const dragRef = useRef3(null);
|
|
1836
|
+
const [preview, setPreview] = useState4(null);
|
|
1837
|
+
const [dragging, setDragging] = useState4(false);
|
|
1838
|
+
const [width, setWidth] = useState4(1e3);
|
|
1839
|
+
useLayoutEffect(() => {
|
|
1840
|
+
const el = rootRef.current;
|
|
1841
|
+
if (!el) return;
|
|
1842
|
+
const measure = () => setWidth(el.clientWidth || 1e3);
|
|
1843
|
+
measure();
|
|
1844
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
1845
|
+
const ro = new ResizeObserver(measure);
|
|
1846
|
+
ro.observe(el);
|
|
1847
|
+
return () => ro.disconnect();
|
|
1848
|
+
}, []);
|
|
1849
|
+
const narrow = width < 600;
|
|
1850
|
+
const [inheritedRtl, setInheritedRtl] = useState4(false);
|
|
1851
|
+
useLayoutEffect(() => {
|
|
1852
|
+
const parent = rootRef.current?.parentElement;
|
|
1853
|
+
if (parent && typeof getComputedStyle === "function") setInheritedRtl(getComputedStyle(parent).direction === "rtl");
|
|
1854
|
+
}, []);
|
|
1855
|
+
const rtl = props.dir ? props.dir === "rtl" : inheritedRtl;
|
|
1856
|
+
const dirAttr = rtl ? "rtl" : void 0;
|
|
1857
|
+
const [settled, setSettled] = useState4(true);
|
|
1858
|
+
useEffect3(() => {
|
|
1859
|
+
if (props.animate === false) return;
|
|
1860
|
+
setSettled(false);
|
|
1861
|
+
const t = setTimeout(() => setSettled(true), SETTLE_MS);
|
|
1862
|
+
return () => clearTimeout(t);
|
|
1863
|
+
}, []);
|
|
1864
|
+
const shown = useMemo2(() => {
|
|
1865
|
+
if (!preview) return editing.shown;
|
|
1866
|
+
return editing.shown.map((i) => i.id === preview.id ? { ...i, start: isoOfDay(preview.start), end: i.end || preview.end !== preview.start ? isoOfDay(preview.end) : void 0 } : i);
|
|
1867
|
+
}, [editing.shown, preview]);
|
|
1868
|
+
const range = useMemo2(() => timelineRange(shown.map(place), todayDay), [shown, todayDay]);
|
|
1869
|
+
const scale = useMemo2(
|
|
1870
|
+
() => createRoadmapScale({ from: range.from, to: range.to, zoom, fiscalYearStart: props.fiscalYearStart, minWidth: Math.max(0, width - laneHeadW - 2) }),
|
|
1871
|
+
[range.from, range.to, zoom, props.fiscalYearStart, width]
|
|
1872
|
+
);
|
|
1873
|
+
const headH = scaleWithText(HEAD_H, textScale) + (scale.months.length ? scaleWithText(MONTH_ROW_H, textScale) : 0);
|
|
1874
|
+
const { lanes, unscheduled } = useMemo2(() => buildLanes(shown, scale, { textScale, laneOrder: props.laneOrder, collapsed, filter }), [shown, scale, props.laneOrder, collapsed, filter]);
|
|
1875
|
+
const placedById = useMemo2(() => {
|
|
1876
|
+
const m = /* @__PURE__ */ new Map();
|
|
1877
|
+
for (const l of lanes) for (const p of l.items) m.set(p.id, { p, lane: l });
|
|
1878
|
+
return m;
|
|
1879
|
+
}, [lanes]);
|
|
1880
|
+
placedRef.current = new Map([...placedById].map(([id, v]) => [id, v.p]));
|
|
1881
|
+
const bodyHeight = lanes.reduce((h, l) => h + l.height, 0);
|
|
1882
|
+
const colorBy = props.colorBy ?? "status";
|
|
1883
|
+
const looks = useMemo2(() => {
|
|
1884
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1885
|
+
return new Map(
|
|
1886
|
+
shown.map((it) => [it.id, appearance(it, { colorBy, accent, statusColors: props.statusColors, laneColors: props.laneColors, seen })])
|
|
1887
|
+
);
|
|
1888
|
+
}, [shown, colorBy, accent, props.statusColors, props.laneColors]);
|
|
1889
|
+
const lookOf = (id) => looks.get(id) ?? { color: accent, text: "#fff", style: "solid" };
|
|
1890
|
+
useEffect3(() => {
|
|
1891
|
+
const el = scrollRef.current;
|
|
1892
|
+
if (!el || mode !== "dates") return;
|
|
1893
|
+
const tx = scale.x(todayDay);
|
|
1894
|
+
el.scrollLeft = Math.max(0, tx - (el.clientWidth - laneHeadW) / 3);
|
|
1895
|
+
}, [zoom, mode]);
|
|
1896
|
+
const goToday = () => {
|
|
1897
|
+
const el = scrollRef.current;
|
|
1898
|
+
if (!el) return;
|
|
1899
|
+
el.scrollLeft = Math.max(0, scale.x(todayDay) - (el.clientWidth - laneHeadW) / 3);
|
|
1900
|
+
announce(`Today, ${dayLabel(todayDay)}`);
|
|
1901
|
+
};
|
|
1902
|
+
const words = (p, lane) => itemWords(p, {
|
|
1903
|
+
lane: lane.label,
|
|
1904
|
+
status: p.item.status,
|
|
1905
|
+
dependsOn: (p.item.dependsOn ?? []).map((d) => placedById.get(d)?.p.item.name ?? "").filter(Boolean)
|
|
1906
|
+
});
|
|
1907
|
+
const anchorOf = (id) => {
|
|
1908
|
+
const el = typeof document !== "undefined" ? document.getElementById(`${uid}-i-${id}`) : null;
|
|
1909
|
+
const r = el?.getBoundingClientRect();
|
|
1910
|
+
return r ? { clientX: r.left + r.width / 2, clientY: r.top + r.height / 2, multi: false } : null;
|
|
1911
|
+
};
|
|
1912
|
+
const select = (id, scrollIntoView = false, multi = false) => {
|
|
1913
|
+
setSelected(id, multi);
|
|
1914
|
+
if (id && scrollIntoView && props.onHover) {
|
|
1915
|
+
const item = placedById.get(id)?.p.item;
|
|
1916
|
+
requestAnimationFrame(() => {
|
|
1917
|
+
const at = anchorOf(id);
|
|
1918
|
+
if (item && at) hostHover(item, at);
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
if (!id || !scrollIntoView) return;
|
|
1922
|
+
const hit = placedById.get(id);
|
|
1923
|
+
const el = scrollRef.current;
|
|
1924
|
+
if (!hit || !el) return;
|
|
1925
|
+
const x = scale.x(hit.p.start);
|
|
1926
|
+
if (x < el.scrollLeft || x > el.scrollLeft + el.clientWidth - laneHeadW - 60) el.scrollLeft = Math.max(0, x - 60);
|
|
1927
|
+
const top = hit.lane.top;
|
|
1928
|
+
if (top < el.scrollTop || top + hit.lane.height > el.scrollTop + el.clientHeight - headH) el.scrollTop = Math.max(0, top - 40);
|
|
1929
|
+
};
|
|
1930
|
+
const neighbour = (id, key) => {
|
|
1931
|
+
const visible = lanes.filter((l) => !l.collapsed && l.items.length);
|
|
1932
|
+
if (!visible.length) return null;
|
|
1933
|
+
const cur = id ? placedById.get(id) : void 0;
|
|
1934
|
+
if (!cur) return visible[0].items[0].id;
|
|
1935
|
+
const li = visible.indexOf(cur.lane);
|
|
1936
|
+
const items2 = cur.lane.items;
|
|
1937
|
+
const i = items2.indexOf(cur.p);
|
|
1938
|
+
if (key === "ArrowRight") return items2[i + 1]?.id ?? cur.p.id;
|
|
1939
|
+
if (key === "ArrowLeft") return items2[i - 1]?.id ?? cur.p.id;
|
|
1940
|
+
const target = visible[li + (key === "ArrowDown" ? 1 : -1)];
|
|
1941
|
+
if (!target) return cur.p.id;
|
|
1942
|
+
let best = target.items[0];
|
|
1943
|
+
for (const p of target.items) if (Math.abs(p.start - cur.p.start) < Math.abs(best.start - cur.p.start)) best = p;
|
|
1944
|
+
return best.id;
|
|
1945
|
+
};
|
|
1946
|
+
const commit = (change) => {
|
|
1947
|
+
if (change) editing.commit(change);
|
|
1948
|
+
};
|
|
1949
|
+
const onGridKeyDown = (e2) => {
|
|
1950
|
+
const mod = e2.ctrlKey || e2.metaKey;
|
|
1951
|
+
if (mod && !e2.altKey && (e2.key === "z" || e2.key === "Z" || e2.key === "y" || e2.key === "Y")) {
|
|
1952
|
+
if (!props.onItemChange) return;
|
|
1953
|
+
if (e2.key === "y" || e2.key === "Y" || e2.shiftKey) editing.redo();
|
|
1954
|
+
else editing.undo();
|
|
1955
|
+
e2.preventDefault();
|
|
1956
|
+
return;
|
|
1957
|
+
}
|
|
1958
|
+
const ke = keyEdit(e2);
|
|
1959
|
+
if (ke) {
|
|
1960
|
+
e2.preventDefault();
|
|
1961
|
+
const hit = selected ? placedById.get(selected) : void 0;
|
|
1962
|
+
if (!hit) return;
|
|
1963
|
+
const action = ke.mode === "end" ? "resize" : "move";
|
|
1964
|
+
if (!can(action, hit.p.item) || ke.mode === "end" && hit.p.milestone) return;
|
|
1965
|
+
commit(spanChange(hit.p, keySpan(ke.mode, hit.p.start, hit.p.end, zoom, ke.dir), "keyboard"));
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
switch (e2.key) {
|
|
1969
|
+
case "ArrowLeft":
|
|
1970
|
+
case "ArrowRight":
|
|
1971
|
+
case "ArrowUp":
|
|
1972
|
+
case "ArrowDown": {
|
|
1973
|
+
const next2 = neighbour(selected, e2.key);
|
|
1974
|
+
if (next2) select(next2, true);
|
|
1975
|
+
break;
|
|
1976
|
+
}
|
|
1977
|
+
case "Home":
|
|
1978
|
+
select(lanes.find((l) => l.items.length)?.items[0]?.id ?? null, true);
|
|
1979
|
+
break;
|
|
1980
|
+
case "End": {
|
|
1981
|
+
const last = [...lanes].reverse().find((l) => l.items.length);
|
|
1982
|
+
select(last?.items[last.items.length - 1]?.id ?? null, true);
|
|
1983
|
+
break;
|
|
1984
|
+
}
|
|
1985
|
+
case "Enter": {
|
|
1986
|
+
const hit = selected ? placedById.get(selected) : void 0;
|
|
1987
|
+
if (hit) props.onOpen?.(hit.p.item);
|
|
1988
|
+
break;
|
|
1989
|
+
}
|
|
1990
|
+
case "Escape":
|
|
1991
|
+
select(null);
|
|
1992
|
+
break;
|
|
1993
|
+
default: {
|
|
1994
|
+
const hit = selected ? placedById.get(selected) : void 0;
|
|
1995
|
+
if (isContextMenuKey(e2) && hit && props.onContextMenu) {
|
|
1996
|
+
const at = anchorOf(hit.p.id);
|
|
1997
|
+
if (at) props.onContextMenu(hit.p.item, at);
|
|
1998
|
+
break;
|
|
1999
|
+
}
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
e2.preventDefault();
|
|
2004
|
+
};
|
|
2005
|
+
const onItemPointerDown = (e2, p) => {
|
|
2006
|
+
if (e2.button !== 0) return;
|
|
2007
|
+
const edge = e2.target.getAttribute("data-edge");
|
|
2008
|
+
const mode2 = edge === "start" ? "start" : edge === "end" ? "end" : "move";
|
|
2009
|
+
select(p.id, false, isMultiSelect(e2));
|
|
2010
|
+
if (!can(mode2 === "move" ? "move" : "resize", p.item)) return;
|
|
2011
|
+
dragRef.current = { id: p.id, mode: mode2, x: e2.clientX, start: p.start, end: p.end, moved: false, pointerId: e2.pointerId, sx: cssScale(rootRef.current).x };
|
|
2012
|
+
const move = (ev) => {
|
|
2013
|
+
const d = dragRef.current;
|
|
2014
|
+
if (!d || ev.pointerId !== d.pointerId) return;
|
|
2015
|
+
const dx = (ev.clientX - d.x) / d.sx;
|
|
2016
|
+
if (!d.moved && Math.abs(dx) < DRAG_THRESHOLD) return;
|
|
2017
|
+
if (!d.moved) {
|
|
2018
|
+
d.moved = true;
|
|
2019
|
+
setDragging(true);
|
|
2020
|
+
}
|
|
2021
|
+
setPreview({ id: d.id, ...dragSpan(d.mode, d.start, d.end, Math.round(dx / scale.dayWidth)) });
|
|
2022
|
+
};
|
|
2023
|
+
const finish = (ev, cancel = false) => {
|
|
2024
|
+
const d = dragRef.current;
|
|
2025
|
+
window.removeEventListener("pointermove", move);
|
|
2026
|
+
window.removeEventListener("pointerup", up);
|
|
2027
|
+
window.removeEventListener("pointercancel", cancelUp);
|
|
2028
|
+
window.removeEventListener("keydown", esc);
|
|
2029
|
+
dragRef.current = null;
|
|
2030
|
+
setDragging(false);
|
|
2031
|
+
if (!d || !d.moved || cancel) {
|
|
2032
|
+
setPreview(null);
|
|
2033
|
+
if (d?.moved && cancel) announce("Move cancelled");
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
const dx = "clientX" in ev && typeof ev.clientX === "number" ? (ev.clientX - d.x) / d.sx : 0;
|
|
2037
|
+
const span = dragSpan(d.mode, d.start, d.end, Math.round(dx / scale.dayWidth));
|
|
2038
|
+
setPreview(null);
|
|
2039
|
+
const base = baseRef.current.find((i) => i.id === d.id);
|
|
2040
|
+
const before = base ? place(base) : null;
|
|
2041
|
+
if (before) commit(spanChange(before, span, "drag"));
|
|
2042
|
+
};
|
|
2043
|
+
const up = (ev) => finish(ev);
|
|
2044
|
+
const cancelUp = (ev) => finish(ev, true);
|
|
2045
|
+
const esc = (ev) => {
|
|
2046
|
+
if (ev.key === "Escape") finish({}, true);
|
|
2047
|
+
};
|
|
2048
|
+
window.addEventListener("pointermove", move);
|
|
2049
|
+
window.addEventListener("pointerup", up);
|
|
2050
|
+
window.addEventListener("pointercancel", cancelUp);
|
|
2051
|
+
window.addEventListener("keydown", esc);
|
|
2052
|
+
};
|
|
2053
|
+
const [exportOpen, setExportOpen] = useState4(false);
|
|
2054
|
+
const exportRef = useRef3(null);
|
|
2055
|
+
useEffect3(() => {
|
|
2056
|
+
if (!exportOpen) return;
|
|
2057
|
+
exportRef.current?.querySelector('[role="menuitem"]')?.focus();
|
|
2058
|
+
const away = (e2) => {
|
|
2059
|
+
if (!exportRef.current?.contains(e2.target)) setExportOpen(false);
|
|
2060
|
+
};
|
|
2061
|
+
document.addEventListener("pointerdown", away, true);
|
|
2062
|
+
return () => document.removeEventListener("pointerdown", away, true);
|
|
2063
|
+
}, [exportOpen]);
|
|
2064
|
+
const exportMenuKeys = (e2) => {
|
|
2065
|
+
const items2 = Array.from(exportRef.current?.querySelectorAll('[role="menuitem"]') ?? []);
|
|
2066
|
+
const i = items2.indexOf(document.activeElement);
|
|
2067
|
+
if (e2.key === "ArrowDown") items2[(i + 1) % items2.length]?.focus();
|
|
2068
|
+
else if (e2.key === "ArrowUp") items2[(i - 1 + items2.length) % items2.length]?.focus();
|
|
2069
|
+
else if (e2.key === "Escape") {
|
|
2070
|
+
setExportOpen(false);
|
|
2071
|
+
exportRef.current?.querySelector("[aria-haspopup]")?.focus();
|
|
2072
|
+
} else return;
|
|
2073
|
+
e2.preventDefault();
|
|
2074
|
+
};
|
|
2075
|
+
const ownerDoc = () => rootRef.current?.ownerDocument ?? document;
|
|
2076
|
+
const exportInput = (forPrint) => ({
|
|
2077
|
+
title,
|
|
2078
|
+
lanes,
|
|
2079
|
+
unscheduled,
|
|
2080
|
+
// Print refits this scale to the page; PNG uses it as is.
|
|
2081
|
+
scale,
|
|
2082
|
+
looks,
|
|
2083
|
+
theme: palette(props.theme, props.accent, forPrint),
|
|
2084
|
+
today: todayDay,
|
|
2085
|
+
printedOn: dayLabel(today()),
|
|
2086
|
+
showDependencies: !!props.showDependencies,
|
|
2087
|
+
mode,
|
|
2088
|
+
fiscalYearStart: props.fiscalYearStart
|
|
2089
|
+
});
|
|
2090
|
+
const copySupport = () => {
|
|
2091
|
+
const host = (props.diagnostics || void 0)?.() ?? {};
|
|
2092
|
+
void copyDiagnostics({
|
|
2093
|
+
product: "Plan",
|
|
2094
|
+
control: "Timeline",
|
|
2095
|
+
version: VERSION,
|
|
2096
|
+
licence: license,
|
|
2097
|
+
rows: props.items.length,
|
|
2098
|
+
...host
|
|
2099
|
+
}).then((r) => editing.toast({ text: r.message, tone: r.ok ? "info" : "error" }));
|
|
2100
|
+
setExportOpen(false);
|
|
2101
|
+
};
|
|
2102
|
+
const printTimeline = () => {
|
|
2103
|
+
setExportOpen(false);
|
|
2104
|
+
printDocument(timelinePrintHtml(exportInput(true)), ownerDoc());
|
|
2105
|
+
announce("Print dialog opened");
|
|
2106
|
+
};
|
|
2107
|
+
const downloadPng = () => {
|
|
2108
|
+
setExportOpen(false);
|
|
2109
|
+
const ok = exportTimelinePng(exportInput(false), exportFileName(title, isoOfDay(today()), "png", "roadmap"), ownerDoc());
|
|
2110
|
+
if (ok) announce("Image downloaded");
|
|
2111
|
+
else editing.toast({ text: "Too big for one image. Filter or collapse lanes first, or use Print.", tone: "info" });
|
|
2112
|
+
};
|
|
2113
|
+
const toggleLane = (key) => {
|
|
2114
|
+
const next2 = new Set(collapsed);
|
|
2115
|
+
if (next2.has(key)) next2.delete(key);
|
|
2116
|
+
else next2.add(key);
|
|
2117
|
+
setCollapsed(next2);
|
|
2118
|
+
announce(`${key} ${next2.has(key) ? "collapsed" : "expanded"}`);
|
|
2119
|
+
};
|
|
2120
|
+
const renderItem = (p, lane) => {
|
|
2121
|
+
const look = lookOf(p.id);
|
|
2122
|
+
const y = LANE_PAD + (lane.row.get(p.id) ?? 0) * rowH;
|
|
2123
|
+
const isSel = selected === p.id;
|
|
2124
|
+
const style2 = { "--bar": look.color, "--bar-text": look.text };
|
|
2125
|
+
const editable = can("move", p.item);
|
|
2126
|
+
const dim = hl && !isHighlighted(p.id, hl) ? "" : void 0;
|
|
2127
|
+
const common = {
|
|
2128
|
+
id: `${uid}-i-${p.id}`,
|
|
2129
|
+
role: "gridcell",
|
|
2130
|
+
"aria-selected": isSel,
|
|
2131
|
+
"data-style": look.style,
|
|
2132
|
+
"data-preview": preview?.id === p.id ? "" : void 0,
|
|
2133
|
+
"data-edit": editable ? "" : void 0,
|
|
2134
|
+
"data-dim": dim,
|
|
2135
|
+
onPointerDown: (e2) => onItemPointerDown(e2, p),
|
|
2136
|
+
onMouseMove: props.onHover ? (e2) => hostHover(p.item, { clientX: e2.clientX, clientY: e2.clientY, multi: isMultiSelect(e2) }) : void 0,
|
|
2137
|
+
onMouseLeave: props.onHover ? (e2) => hostHover(null, { clientX: e2.clientX, clientY: e2.clientY, multi: false }) : void 0,
|
|
2138
|
+
onContextMenu: props.onContextMenu ? (e2) => {
|
|
2139
|
+
e2.preventDefault();
|
|
2140
|
+
props.onContextMenu(p.item, { clientX: e2.clientX, clientY: e2.clientY, multi: isMultiSelect(e2) });
|
|
2141
|
+
} : void 0,
|
|
2142
|
+
onDoubleClick: () => props.onOpen?.(p.item)
|
|
2143
|
+
};
|
|
2144
|
+
const sr = /* @__PURE__ */ React5.createElement("span", { className: "nf-t-sr" }, words(p, lane));
|
|
2145
|
+
if (p.milestone) {
|
|
2146
|
+
const cx = scale.x(p.start) + scale.dayWidth / 2;
|
|
2147
|
+
return /* @__PURE__ */ React5.createElement(React5.Fragment, { key: p.id }, /* @__PURE__ */ React5.createElement("div", { ...common, className: "nf-t-ms", style: { ...style2, left: cx - 7, top: y + rowH / 2 - 9 } }, sr), /* @__PURE__ */ React5.createElement("span", { className: "nf-t-label", "aria-hidden": "true", "data-dim": dim, style: { left: cx + 12, top: y + (rowH - labelLineH) / 2 } }, p.item.name));
|
|
2148
|
+
}
|
|
2149
|
+
const x0 = scale.x(p.start);
|
|
2150
|
+
const w = Math.max(6, scale.x(p.end + 1) - x0);
|
|
2151
|
+
const inside = w >= LABEL_INSIDE_MIN * textScale;
|
|
2152
|
+
const resizable = can("resize", p.item);
|
|
2153
|
+
return /* @__PURE__ */ React5.createElement(React5.Fragment, { key: p.id }, /* @__PURE__ */ React5.createElement("div", { ...common, className: "nf-t-bar", style: { ...style2, left: x0, width: w, top: y + 4 } }, sr, inside ? /* @__PURE__ */ React5.createElement("span", { className: "nf-t-barname", "aria-hidden": "true" }, p.item.name) : null, resizable ? /* @__PURE__ */ React5.createElement(React5.Fragment, null, /* @__PURE__ */ React5.createElement("span", { className: "nf-t-h", "data-edge": "start", "aria-hidden": "true" }), /* @__PURE__ */ React5.createElement("span", { className: "nf-t-h", "data-edge": "end", "aria-hidden": "true" })) : null), !inside ? /* @__PURE__ */ React5.createElement("span", { className: "nf-t-label", "aria-hidden": "true", "data-dim": dim, style: { left: x0 + w + 6, top: y + 8 } }, p.item.name) : null, preview?.id === p.id ? /* @__PURE__ */ React5.createElement("div", { className: "nf-t-draglabel", "aria-hidden": "true", style: { left: x0, top: Math.max(0, y - 22) } }, rangeLabel(p.start, p.end)) : null);
|
|
2154
|
+
};
|
|
2155
|
+
const connectors = () => {
|
|
2156
|
+
if (!props.showDependencies) return null;
|
|
2157
|
+
const paths = [];
|
|
2158
|
+
for (const { p, lane } of placedById.values()) {
|
|
2159
|
+
if (lane.collapsed) continue;
|
|
2160
|
+
for (const dep of p.item.dependsOn ?? []) {
|
|
2161
|
+
const from = placedById.get(dep);
|
|
2162
|
+
if (!from || from.lane.collapsed) continue;
|
|
2163
|
+
const fy = from.lane.top + LANE_PAD + (from.lane.row.get(from.p.id) ?? 0) * rowH + rowH / 2;
|
|
2164
|
+
const ty = lane.top + LANE_PAD + (lane.row.get(p.id) ?? 0) * rowH + rowH / 2;
|
|
2165
|
+
const fx = from.p.milestone ? scale.x(from.p.start) + scale.dayWidth / 2 + 7 : scale.x(from.p.end + 1);
|
|
2166
|
+
const tx = p.milestone ? scale.x(p.start) + scale.dayWidth / 2 - 7 : scale.x(p.start);
|
|
2167
|
+
paths.push(connectorPath(fx, fy, tx, ty, rowH));
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
if (!paths.length) return null;
|
|
2171
|
+
return /* @__PURE__ */ React5.createElement("svg", { className: "nf-t-deps", width: scale.width, height: bodyHeight, "aria-hidden": "true" }, paths.map((d, i) => /* @__PURE__ */ React5.createElement("path", { key: i, d, markerEnd: `url(#${uid}-arrow)` })), /* @__PURE__ */ React5.createElement("defs", null, /* @__PURE__ */ React5.createElement("marker", { id: `${uid}-arrow`, viewBox: "0 0 6 6", refX: "5", refY: "3", markerWidth: "6", markerHeight: "6", orient: "auto" }, /* @__PURE__ */ React5.createElement("path", { d: "M0,0 L6,3 L0,6 z", style: { fill: "var(--nf-muted)", stroke: "none" } }))));
|
|
2172
|
+
};
|
|
2173
|
+
const legendEntries = useMemo2(() => {
|
|
2174
|
+
const out = /* @__PURE__ */ new Map();
|
|
2175
|
+
for (const it of shown) {
|
|
2176
|
+
const key = colorBy === "lane" ? (it.lane ?? "").trim() || NO_LANE : (it.status ?? "").trim();
|
|
2177
|
+
if (key && !out.has(key)) out.set(key, lookOf(it.id));
|
|
2178
|
+
}
|
|
2179
|
+
return [...out.entries()];
|
|
2180
|
+
}, [shown, looks, colorBy]);
|
|
2181
|
+
const style = {
|
|
2182
|
+
...resolveTheme(props.theme, props.accent, props.colors),
|
|
2183
|
+
...textVars(textScale, props.textSizes),
|
|
2184
|
+
"--nf-row": `${rowH}px`,
|
|
2185
|
+
"--nf-lanehead": `${laneHeadW}px`,
|
|
2186
|
+
"--nf-head": `${scaleWithText(HEAD_H, textScale)}px`,
|
|
2187
|
+
height: props.height ?? "100%"
|
|
2188
|
+
};
|
|
2189
|
+
const showToolbar = props.showToolbar !== false;
|
|
2190
|
+
const todayX = todayDay >= scale.startDay && todayDay < scale.endDay ? scale.x(todayDay) + scale.dayWidth / 2 : null;
|
|
2191
|
+
const totalItems = shown.length;
|
|
2192
|
+
return /* @__PURE__ */ React5.createElement(
|
|
2193
|
+
"div",
|
|
2194
|
+
{
|
|
2195
|
+
ref: rootRef,
|
|
2196
|
+
dir: "ltr",
|
|
2197
|
+
"data-rtl": rtl ? "" : void 0,
|
|
2198
|
+
"data-narrow": narrow ? "" : void 0,
|
|
2199
|
+
"data-static": props.animate === false ? "" : void 0,
|
|
2200
|
+
"data-hl": hl ? "" : void 0,
|
|
2201
|
+
"data-months": scale.months.length ? "" : void 0,
|
|
2202
|
+
className: `nf-tl${settled ? " nf-t-settled" : ""}${dragging ? " nf-t-dragging" : ""}${props.className ? " " + props.className : ""}`,
|
|
2203
|
+
style
|
|
2204
|
+
},
|
|
2205
|
+
/* @__PURE__ */ React5.createElement("style", { dangerouslySetInnerHTML: { __html: css } }),
|
|
2206
|
+
showToolbar ? /* @__PURE__ */ React5.createElement("div", { className: "nf-t-toolbar", dir: dirAttr }, /* @__PURE__ */ React5.createElement("div", { className: "nf-t-title" }, title), /* @__PURE__ */ React5.createElement("div", { className: "nf-t-seg", role: "group", "aria-label": "Mode" }, MODES.map((m) => /* @__PURE__ */ React5.createElement("button", { key: m.id, type: "button", className: "nf-t-btn", "aria-pressed": m.id === mode, onClick: () => setMode(m.id) }, m.label))), mode === "dates" ? /* @__PURE__ */ React5.createElement(React5.Fragment, null, /* @__PURE__ */ React5.createElement("div", { className: "nf-t-seg", role: "group", "aria-label": "Zoom" }, ZOOMS.map((z) => /* @__PURE__ */ React5.createElement("button", { key: z.id, type: "button", className: "nf-t-btn", "aria-pressed": z.id === zoom, onClick: () => setZoom(z.id) }, z.label))), /* @__PURE__ */ React5.createElement("button", { type: "button", className: "nf-t-btn", onClick: goToday }, "Today")) : null, /* @__PURE__ */ React5.createElement("div", { className: "nf-t-spacer" }), /* @__PURE__ */ React5.createElement(
|
|
2207
|
+
"input",
|
|
2208
|
+
{
|
|
2209
|
+
className: "nf-t-filter",
|
|
2210
|
+
type: "search",
|
|
2211
|
+
placeholder: "Filter items",
|
|
2212
|
+
"aria-label": "Filter items by name, lane or status",
|
|
2213
|
+
value: filter,
|
|
2214
|
+
onChange: (e2) => setFilter(e2.target.value),
|
|
2215
|
+
onKeyDown: (e2) => {
|
|
2216
|
+
if (e2.key === "Escape" && filter) {
|
|
2217
|
+
setFilter("");
|
|
2218
|
+
e2.preventDefault();
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
), mode === "dates" && unscheduled.length ? /* @__PURE__ */ React5.createElement("button", { type: "button", className: "nf-t-btn", "aria-pressed": trayOpen, "aria-controls": trayOpen ? `${uid}-tray` : void 0, onClick: () => setTrayOpen(!trayOpen) }, "Not scheduled", /* @__PURE__ */ React5.createElement("span", { className: "nf-t-count" }, unscheduled.length)) : null, props.exports !== false ? /* @__PURE__ */ React5.createElement("div", { className: "nf-t-menuwrap", ref: exportRef, onKeyDown: exportMenuKeys }, /* @__PURE__ */ React5.createElement("button", { type: "button", className: "nf-t-btn", "aria-haspopup": "menu", "aria-expanded": exportOpen, onClick: () => setExportOpen(!exportOpen) }, "Export"), exportOpen ? /* @__PURE__ */ React5.createElement("div", { className: "nf-t-menu", role: "menu", "aria-label": "Export" }, /* @__PURE__ */ React5.createElement("button", { type: "button", role: "menuitem", tabIndex: -1, onClick: printTimeline }, "Print\u2026"), mode === "dates" ? /* @__PURE__ */ React5.createElement("button", { type: "button", role: "menuitem", tabIndex: -1, onClick: downloadPng }, "Download PNG") : null, props.diagnostics !== false ? /* @__PURE__ */ React5.createElement("button", { type: "button", role: "menuitem", tabIndex: -1, onClick: copySupport }, "Copy diagnostics") : null) : null) : null) : null,
|
|
2223
|
+
/* @__PURE__ */ React5.createElement("div", { className: "nf-t-main" }, mode === "horizon" ? /* @__PURE__ */ React5.createElement(
|
|
2224
|
+
HorizonBoard,
|
|
2225
|
+
{
|
|
2226
|
+
items: shown.filter((i) => [i.name, i.lane, i.status].some((v) => (v ?? "").toLowerCase().includes(filter.trim().toLowerCase()))),
|
|
2227
|
+
laneOrder: props.laneOrder,
|
|
2228
|
+
looks,
|
|
2229
|
+
selected,
|
|
2230
|
+
onSelect: (id) => select(id),
|
|
2231
|
+
onOpen: props.onOpen,
|
|
2232
|
+
dir: dirAttr,
|
|
2233
|
+
label: `${title}, now, next and later`
|
|
2234
|
+
}
|
|
2235
|
+
) : /* @__PURE__ */ React5.createElement(
|
|
2236
|
+
"div",
|
|
2237
|
+
{
|
|
2238
|
+
ref: scrollRef,
|
|
2239
|
+
className: "nf-t-scroll",
|
|
2240
|
+
role: "grid",
|
|
2241
|
+
"aria-label": `${title}, ${scale.minor[0]?.label ?? ""} to ${scale.minor[scale.minor.length - 1]?.label ?? ""}`,
|
|
2242
|
+
"aria-busy": props.loading ? true : void 0,
|
|
2243
|
+
"aria-rowcount": lanes.length + 1,
|
|
2244
|
+
"aria-activedescendant": selected && placedById.has(selected) ? `${uid}-i-${selected}` : void 0,
|
|
2245
|
+
"aria-describedby": `${uid}-help`,
|
|
2246
|
+
tabIndex: 0,
|
|
2247
|
+
onKeyDown: onGridKeyDown
|
|
2248
|
+
},
|
|
2249
|
+
/* @__PURE__ */ React5.createElement("div", { className: "nf-t-head", role: "row", "aria-rowindex": 1, style: { width: laneHeadW + scale.width, height: headH } }, /* @__PURE__ */ React5.createElement("div", { className: "nf-t-corner", role: "columnheader", dir: dirAttr }, "Lanes"), /* @__PURE__ */ React5.createElement("div", { className: "nf-t-axis", role: "columnheader", style: { width: scale.width, height: headH } }, /* @__PURE__ */ React5.createElement("span", { className: "nf-t-sr" }, `${scale.minor[0]?.label ?? ""} to ${scale.minor[scale.minor.length - 1]?.label ?? ""}`), scale.major.map((t) => /* @__PURE__ */ React5.createElement("div", { key: `M${t.day}`, className: "nf-t-major", "aria-hidden": "true", style: { left: t.x, width: t.width } }, t.label)), scale.minor.map((t) => /* @__PURE__ */ React5.createElement("div", { key: `m${t.day}`, className: "nf-t-minor", "aria-hidden": "true", style: { left: t.x, width: t.width } }, t.label)), scale.months.map((t) => /* @__PURE__ */ React5.createElement("div", { key: `mo${t.day}`, className: "nf-t-month", "aria-hidden": "true", style: { left: t.x, width: t.width } }, monthLabel(t.label, t.width, textSize("caption", textScale, props.textSizes)))))),
|
|
2250
|
+
/* @__PURE__ */ React5.createElement("div", { className: "nf-t-body", style: { height: Math.max(bodyHeight, 120), width: laneHeadW + scale.width } }, /* @__PURE__ */ React5.createElement("div", { className: "nf-t-layer", "aria-hidden": "true", style: { left: laneHeadW, width: scale.width } }, scale.minor.map((t) => /* @__PURE__ */ React5.createElement("div", { key: `v${t.day}`, className: "nf-t-vline", "data-major": scale.major.some((m) => m.day === t.day) ? "" : void 0, style: { left: t.x } })), todayX !== null ? /* @__PURE__ */ React5.createElement("div", { className: "nf-t-today", style: { left: todayX } }) : null), lanes.map((lane) => /* @__PURE__ */ React5.createElement(
|
|
2251
|
+
"div",
|
|
2252
|
+
{
|
|
2253
|
+
key: lane.key,
|
|
2254
|
+
className: "nf-t-lane",
|
|
2255
|
+
role: "row",
|
|
2256
|
+
"aria-rowindex": lane.index + 2,
|
|
2257
|
+
"data-alt": lane.index % 2 ? "" : void 0,
|
|
2258
|
+
style: { top: lane.top, height: lane.height, width: laneHeadW + scale.width }
|
|
2259
|
+
},
|
|
2260
|
+
/* @__PURE__ */ React5.createElement("div", { className: "nf-t-lanehead", role: "rowheader", dir: dirAttr, style: { height: lane.height } }, /* @__PURE__ */ React5.createElement("button", { type: "button", className: "nf-t-lanebtn", "aria-expanded": !lane.collapsed, tabIndex: -1, onClick: () => toggleLane(lane.key) }, /* @__PURE__ */ React5.createElement(Chevron, null), /* @__PURE__ */ React5.createElement("span", null, lane.label), /* @__PURE__ */ React5.createElement("span", { className: "nf-t-lanecount" }, lane.items.length))),
|
|
2261
|
+
/* @__PURE__ */ React5.createElement("div", { className: "nf-t-track", style: { width: scale.width, height: lane.height } }, lane.collapsed ? null : lane.items.map((p) => renderItem(p, lane)))
|
|
2262
|
+
)), /* @__PURE__ */ React5.createElement("div", { className: "nf-t-depwrap", style: { left: laneHeadW } }, connectors()), lanes.length === 0 ? /* @__PURE__ */ React5.createElement("div", { className: "nf-t-empty", role: "status" }, props.loading ? "Loading items\u2026" : filter ? `No items match \u201C${filter}\u201D.` : totalItems ? "No items have dates yet." : "No items yet.", props.emptyAction && !props.loading && !filter && !totalItems ? /* @__PURE__ */ React5.createElement(EmptyActionView, { action: props.emptyAction, prefix: "nf-t", buttonClass: "nf-t-btn" }) : null) : null)
|
|
2263
|
+
), mode === "dates" && trayOpen && unscheduled.length ? /* @__PURE__ */ React5.createElement("aside", { className: "nf-t-tray", id: `${uid}-tray`, "aria-label": "Not scheduled", dir: dirAttr }, /* @__PURE__ */ React5.createElement("div", { className: "nf-t-trayhead" }, "Not scheduled", /* @__PURE__ */ React5.createElement("span", { className: "nf-t-count" }, unscheduled.length)), /* @__PURE__ */ React5.createElement("ul", { className: "nf-t-traylist" }, unscheduled.map((it) => {
|
|
2264
|
+
const look = lookOf(it.id);
|
|
2265
|
+
return /* @__PURE__ */ React5.createElement("li", { key: it.id }, /* @__PURE__ */ React5.createElement(
|
|
2266
|
+
"button",
|
|
2267
|
+
{
|
|
2268
|
+
type: "button",
|
|
2269
|
+
className: "nf-t-trayitem",
|
|
2270
|
+
"aria-pressed": selected === it.id,
|
|
2271
|
+
style: { "--bar": look.color },
|
|
2272
|
+
onClick: () => select(it.id),
|
|
2273
|
+
onDoubleClick: () => props.onOpen?.(it),
|
|
2274
|
+
onKeyDown: (e2) => {
|
|
2275
|
+
if (e2.key === "Enter") {
|
|
2276
|
+
e2.preventDefault();
|
|
2277
|
+
props.onOpen?.(it);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
},
|
|
2281
|
+
it.name,
|
|
2282
|
+
/* @__PURE__ */ React5.createElement("small", null, [it.lane || NO_LANE, it.status, it.horizon].filter(Boolean).join(" \xB7 "))
|
|
2283
|
+
));
|
|
2284
|
+
}))) : null),
|
|
2285
|
+
legendEntries.length ? /* @__PURE__ */ React5.createElement("div", { className: "nf-t-legend", dir: dirAttr, "aria-label": "Legend" }, legendEntries.map(([key, look]) => /* @__PURE__ */ React5.createElement("span", { key }, /* @__PURE__ */ React5.createElement("i", { "aria-hidden": "true", "data-style": look.style, style: { "--sw": look.color } }), key)), mode === "dates" ? /* @__PURE__ */ React5.createElement("span", null, /* @__PURE__ */ React5.createElement("i", { "aria-hidden": "true", style: { "--sw": "var(--nf-ink)", width: 9, height: 9, transform: "rotate(45deg)", borderRadius: 2 } }), "Milestone") : null, mode === "dates" && todayX !== null ? /* @__PURE__ */ React5.createElement("span", null, /* @__PURE__ */ React5.createElement("i", { "aria-hidden": "true", style: { "--sw": "var(--nf-now)", width: 2, height: 12, borderRadius: 0 } }), "Today") : null) : null,
|
|
2286
|
+
/* @__PURE__ */ React5.createElement("div", { id: `${uid}-help`, className: "nf-t-sr" }, "Arrow keys move between items: left and right along a lane, up and down to the next lane. Enter opens.", props.onItemChange ? " Alt plus left or right moves the selected item by a week at month zoom, or a month at quarter and half-year zoom. Alt plus Shift plus left or right changes its end. Control plus Z undoes." : ""),
|
|
2287
|
+
/* @__PURE__ */ React5.createElement(Toasts, { prefix: "nf-t", items: editing.toasts, announcement: editing.announcement, onDismiss: editing.dismiss }),
|
|
2288
|
+
license && !license.licensed && props.watermark !== false ? /* @__PURE__ */ React5.createElement(Watermark, { prefix: "nf-t", title: licenseMessage(license), markHtml: markSvg({ size: 16 }), variant: props.watermarkStyle, onClick: props.onWatermarkClick }) : null,
|
|
2289
|
+
editing.lockNote ? /* @__PURE__ */ React5.createElement("p", { className: "nf-t-locknote", role: "status" }, editing.lockNote) : null,
|
|
2290
|
+
renewal ? /* @__PURE__ */ React5.createElement("a", { className: "nf-t-renew", href: renewal.href, target: "_blank", rel: "noreferrer", title: renewal.detail }, renewal.text) : null
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
function HorizonBoard(props) {
|
|
2294
|
+
const cols = horizonColumns(props.items);
|
|
2295
|
+
const laneSet = new Set(props.items.map((i) => (i.lane ?? "").trim() || NO_LANE));
|
|
2296
|
+
const ordered = (props.laneOrder ?? []).filter((l) => laneSet.has(l));
|
|
2297
|
+
const lanes = [...ordered, ...[...laneSet].filter((l) => !ordered.includes(l) && l !== NO_LANE).sort((a, b) => a.localeCompare(b)), ...laneSet.has(NO_LANE) ? [NO_LANE] : []];
|
|
2298
|
+
const template = `minmax(140px, 200px) repeat(${cols.length}, minmax(160px, 1fr))`;
|
|
2299
|
+
return /* @__PURE__ */ React5.createElement("div", { className: "nf-t-nnl", role: "region", "aria-label": props.label, dir: props.dir }, /* @__PURE__ */ React5.createElement("div", { className: "nf-t-nnlgrid", style: { gridTemplateColumns: template } }, /* @__PURE__ */ React5.createElement("div", { className: "nf-t-nnlhead" }, "Lane"), cols.map((c) => /* @__PURE__ */ React5.createElement("div", { key: c.key, className: "nf-t-nnlhead" }, c.key, /* @__PURE__ */ React5.createElement("span", { className: "nf-t-lanecount" }, c.items.length))), lanes.map((lane, li) => /* @__PURE__ */ React5.createElement(React5.Fragment, { key: lane }, /* @__PURE__ */ React5.createElement("div", { className: "nf-t-nnllane", "data-alt": li % 2 ? "" : void 0 }, lane), cols.map((c) => {
|
|
2300
|
+
const cards = c.items.filter((i) => ((i.lane ?? "").trim() || NO_LANE) === lane);
|
|
2301
|
+
return /* @__PURE__ */ React5.createElement("div", { key: c.key, className: "nf-t-nnlcell", "data-alt": li % 2 ? "" : void 0, role: "group", "aria-label": `${lane}, ${c.key}` }, cards.map((it) => {
|
|
2302
|
+
const look = props.looks.get(it.id);
|
|
2303
|
+
const p = place(it);
|
|
2304
|
+
const when = p ? p.milestone ? dayLabel(p.start) : rangeLabel(p.start, p.end) : "Not scheduled";
|
|
2305
|
+
return /* @__PURE__ */ React5.createElement(
|
|
2306
|
+
"button",
|
|
2307
|
+
{
|
|
2308
|
+
key: it.id,
|
|
2309
|
+
type: "button",
|
|
2310
|
+
className: "nf-t-card",
|
|
2311
|
+
"aria-pressed": props.selected === it.id,
|
|
2312
|
+
style: { "--bar": look?.color },
|
|
2313
|
+
onClick: () => props.onSelect(it.id),
|
|
2314
|
+
onDoubleClick: () => props.onOpen?.(it),
|
|
2315
|
+
onKeyDown: (e2) => {
|
|
2316
|
+
if (e2.key === "Enter") {
|
|
2317
|
+
e2.preventDefault();
|
|
2318
|
+
props.onOpen?.(it);
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
},
|
|
2322
|
+
it.name,
|
|
2323
|
+
/* @__PURE__ */ React5.createElement("small", null, [it.status, when].filter(Boolean).join(" \xB7 "))
|
|
2324
|
+
);
|
|
2325
|
+
}));
|
|
2326
|
+
})))));
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
// src/sample.ts
|
|
2330
|
+
var ROWS = [
|
|
2331
|
+
["Self-serve onboarding", "Customer", "2026-07-06", "2026-09-25", "Done", "Now"],
|
|
2332
|
+
["In-app help centre", "Customer", "2026-08-17", "2026-11-27", "On track", "Now"],
|
|
2333
|
+
["Customer portal v2", "Customer", "2026-10-05", "2027-03-26", "At risk", "Now", "Self-serve onboarding"],
|
|
2334
|
+
["NPS programme", "Customer", "2027-01-11", "2027-04-30", "Not started", "Next"],
|
|
2335
|
+
["Mobile app beta", "Customer", "2027-04-05", "2027-08-27", "Not started", "Next", "Customer portal v2"],
|
|
2336
|
+
["Portal launch", "Customer", "2027-03-29", null, "Not started", "Next", "Customer portal v2"],
|
|
2337
|
+
["Loyalty scheme", "Customer", "2027-08-02", "2027-12-17", "Not started", "Later"],
|
|
2338
|
+
["Pricing review", "Growth", "2026-07-13", "2026-08-28", "Done", "Now"],
|
|
2339
|
+
["Partner programme", "Growth", "2026-09-07", "2027-01-29", "On track", "Now"],
|
|
2340
|
+
["APAC expansion", "Growth", "2026-11-02", "2027-06-25", "At risk", "Now"],
|
|
2341
|
+
["Marketplace listing", "Growth", "2027-02-01", "2027-03-12", "Not started", "Next", "Partner programme"],
|
|
2342
|
+
["EMEA launch", "Growth", "2027-07-05", "2027-12-10", "Not started", "Later", "APAC expansion"],
|
|
2343
|
+
["Board strategy day", "Growth", "2026-11-20", "2026-11-20", "On track", "Now", null, false],
|
|
2344
|
+
["FY27 plan sign-off", "Growth", "2026-07-31", null, "Done", "Now"],
|
|
2345
|
+
["Cloud migration", "Platform", "2026-07-06", "2026-12-18", "On track", "Now"],
|
|
2346
|
+
["API v3", "Platform", "2026-09-14", "2027-02-26", "Off track", "Now"],
|
|
2347
|
+
["Event bus", "Platform", "2026-10-12", "2027-01-22", "On track", "Now", "Cloud migration"],
|
|
2348
|
+
["Legacy decommission", "Platform", "2027-01-04", "2027-05-28", "Not started", "Next", "Cloud migration"],
|
|
2349
|
+
["Multi-region", "Platform", "2027-05-03", "2027-10-29", "Not started", "Later", "Legacy decommission"],
|
|
2350
|
+
["Cut-over weekend", "Platform", "2026-12-12", null, "On track", "Now", "Cloud migration"],
|
|
2351
|
+
["Platform review", "Platform", "2027-02-15", "2027-02-15", "Not started", "Next", null, false],
|
|
2352
|
+
["Data warehouse", "Data", "2026-07-20", "2026-12-11", "On track", "Now"],
|
|
2353
|
+
["Self-serve reporting", "Data", "2026-10-19", "2027-03-19", "At risk", "Now", "Data warehouse"],
|
|
2354
|
+
["Data quality", "Data", "2026-08-03", "2026-10-30", "Done", "Now"],
|
|
2355
|
+
["ML forecasting", "Data", "2027-02-01", "2027-07-30", "Not started", "Next", "Data warehouse"],
|
|
2356
|
+
["Data catalogue", "Data", "2027-06-07", "2027-11-26", "Not started", "Later"],
|
|
2357
|
+
["Warehouse live", "Data", "2026-12-14", null, "On track", "Now", "Data warehouse"],
|
|
2358
|
+
["ISO 27001", "Security", "2026-07-06", "2027-06-25", "On track", "Now"],
|
|
2359
|
+
["SSO everywhere", "Security", "2026-09-01", "2026-11-30", "Off track", "Now"],
|
|
2360
|
+
["Pen test", "Security", "2026-11-16", "2026-11-27", "Not started", "Now"],
|
|
2361
|
+
["Zero trust network", "Security", "2027-01-11", "2027-08-27", "Not started", "Next", "SSO everywhere"],
|
|
2362
|
+
["Certification audit", "Security", "2027-06-28", null, "Not started", "Next", "ISO 27001"],
|
|
2363
|
+
["Security awareness", "Security", "2027-03-01", "2027-04-30", "Not started", "Next"],
|
|
2364
|
+
["New office fit-out", "Operations", "2026-08-03", "2026-10-16", "Done", "Now"],
|
|
2365
|
+
["ERP upgrade", "Operations", "2026-10-05", "2027-04-30", "At risk", "Now"],
|
|
2366
|
+
["Procurement portal", "Operations", "2026-12-07", "2027-03-26", "On track", "Now"],
|
|
2367
|
+
["Finance close automation", "Operations", "2027-03-01", "2027-07-30", "Not started", "Next", "ERP upgrade"],
|
|
2368
|
+
["ERP go-live", "Operations", "2027-05-03", null, "Not started", "Next", "ERP upgrade"],
|
|
2369
|
+
["Sustainability report", "Operations", "2027-09-06", "2027-11-12", "Not started", "Later"],
|
|
2370
|
+
["Year-end close", "Operations", "2027-06-30", null, "Not started", "Next"],
|
|
2371
|
+
["Offsite", "Operations", "2027-02-24", "2027-02-24", "Not started", "Next", null, false],
|
|
2372
|
+
["Voice assistant", "Customer", null, null, "Not started", "Later"],
|
|
2373
|
+
["Open data API", "Data", null, null, "Not started", "Later"],
|
|
2374
|
+
["Carbon tracking", "Operations", null, null, "Not started", "Later"]
|
|
2375
|
+
];
|
|
2376
|
+
var SAMPLE_LANES = ["Customer", "Growth", "Platform", "Data", "Security", "Operations"];
|
|
2377
|
+
var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
2378
|
+
function sampleRoadmap() {
|
|
2379
|
+
return ROWS.map(([name, lane, start, end, status, horizon, dep, milestone]) => ({
|
|
2380
|
+
id: slug(name),
|
|
2381
|
+
name,
|
|
2382
|
+
lane,
|
|
2383
|
+
start: start ?? void 0,
|
|
2384
|
+
end: end ?? void 0,
|
|
2385
|
+
status,
|
|
2386
|
+
horizon,
|
|
2387
|
+
dependsOn: dep ? [slug(dep)] : void 0,
|
|
2388
|
+
milestone
|
|
2389
|
+
}));
|
|
2390
|
+
}
|
|
2391
|
+
export {
|
|
2392
|
+
PALETTE,
|
|
2393
|
+
SAMPLE_LANES,
|
|
2394
|
+
Timeline,
|
|
2395
|
+
VERSION,
|
|
2396
|
+
appearance,
|
|
2397
|
+
buildLanes,
|
|
2398
|
+
horizonColumns,
|
|
2399
|
+
packRows,
|
|
2400
|
+
place,
|
|
2401
|
+
sampleRoadmap,
|
|
2402
|
+
timelinePrintHtml
|
|
2403
|
+
};
|
|
2404
|
+
//# sourceMappingURL=index.js.map
|