diff --git a/packages/merman/src/detect.ts b/packages/merman/src/detect.ts index 3e8443a833..443ae61cda 100644 --- a/packages/merman/src/detect.ts +++ b/packages/merman/src/detect.ts @@ -2,10 +2,12 @@ import type { MermaidDiagramKind } from "./diagnostics.js" import { isMermaidFlowchartDiagram } from "./flowchart/parser.js" import { isMermaidSequenceDiagram } from "./sequence/parser.js" import { isMermaidStateDiagram } from "./state/parser.js" +import { isMermaidTimelineDiagram } from "./timeline/parser.js" export function detectMermaidDiagram(content: string): MermaidDiagramKind | undefined { if (isMermaidFlowchartDiagram(content)) return "flowchart" if (isMermaidSequenceDiagram(content)) return "sequence" if (isMermaidStateDiagram(content)) return "state" + if (isMermaidTimelineDiagram(content)) return "timeline" return undefined } diff --git a/packages/merman/src/diagnostics.ts b/packages/merman/src/diagnostics.ts index 7336bfcaa8..8a93661888 100644 --- a/packages/merman/src/diagnostics.ts +++ b/packages/merman/src/diagnostics.ts @@ -1,4 +1,4 @@ -export type MermaidDiagramKind = "flowchart" | "sequence" | "state" +export type MermaidDiagramKind = "flowchart" | "sequence" | "state" | "timeline" /** An otherwise valid diagram contains syntax that this renderer does not support. */ export class MermaidSyntaxError extends Error { diff --git a/packages/merman/src/markdown.ts b/packages/merman/src/markdown.ts index 136b5cab16..e933742869 100644 --- a/packages/merman/src/markdown.ts +++ b/packages/merman/src/markdown.ts @@ -25,6 +25,10 @@ import { drawStateDiagramGrid } from "./state/drawing.js" import { parseMermaidStateDiagram } from "./state/parser.js" import { renderStateGridStyledText } from "./state/render-grid.js" import { resolveStateStyleColors } from "./state/style.js" +import { drawTimelineDiagramGrid } from "./timeline/drawing.js" +import { parseMermaidTimelineDiagram } from "./timeline/parser.js" +import { renderTimelineGridStyledText } from "./timeline/render-grid.js" +import { resolveTimelineStyleColors } from "./timeline/style.js" type DiagramKind = NonNullable> @@ -180,6 +184,25 @@ function prepareDiagram( height: size.height, } } + case "timeline": { + const grid = drawTimelineDiagramGrid(parseMermaidTimelineDiagram(source)) + const size = grid.getTextSize({ trimBottom: true }) + return { + kind, + source, + text: renderTimelineGridStyledText( + grid, + resolveTimelineStyleColors({ + title: color(colors.text), + section: color(colors.secondary), + period: color(colors.warning), + spine: color(colors.muted), + event: color(colors.primary), + }), + ), + height: size.height, + } + } } } diff --git a/packages/merman/src/test/diagnostics.test.ts b/packages/merman/src/test/diagnostics.test.ts index 18f01cf363..5ccebb2ac6 100644 --- a/packages/merman/src/test/diagnostics.test.ts +++ b/packages/merman/src/test/diagnostics.test.ts @@ -3,6 +3,7 @@ import { MermaidSyntaxError } from "../diagnostics.js" import { parseMermaidFlowchartDiagram } from "../flowchart/parser.js" import { parseMermaidSequenceDiagram } from "../sequence/parser.js" import { parseMermaidStateDiagram } from "../state/parser.js" +import { renderTimelineDiagram } from "../timeline/diagram.js" import { renderSequenceDiagram } from "../sequence/diagram.js" describe("parser diagnostics", () => { @@ -104,6 +105,12 @@ describe("parser diagnostics", () => { ).toThrow('Unexpected "end" without an open block in sequence diagram at line 2: "end"') }) + test("reports malformed timeline continuations with timeline diagnostics", () => { + expect(() => renderTimelineDiagram("timeline\n : orphan event")).toThrow( + 'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"', + ) + }) + test("does not attach else through an unclosed nested sequence block", () => { expect(() => parseMermaidSequenceDiagram(`sequenceDiagram diff --git a/packages/merman/src/test/markdown.test.ts b/packages/merman/src/test/markdown.test.ts index 056b0f636a..49c3423c2d 100644 --- a/packages/merman/src/test/markdown.test.ts +++ b/packages/merman/src/test/markdown.test.ts @@ -333,3 +333,31 @@ stateDiagram-v2 expect(frame).toContain("Idle") expect(frame).not.toContain("stateDiagram-v2") }) + +test("renders a Mermaid timeline fence inside MarkdownRenderable", async () => { + const testRenderer = await createTestRenderer({ width: 80, height: 18 }) + renderer = testRenderer.renderer + const { renderOnce, captureCharFrame } = testRenderer + const markdown = new MarkdownRenderable(renderer, { + id: "markdown-timeline", + content: `\`\`\`mermaid +timeline + title Product history + section Foundation + 2024 : Prototype + : First release +\`\`\``, + syntaxStyle, + treeSitterClient, + renderNode: createMermaidMarkdownRenderer(renderer), + }) + + renderer.root.add(markdown) + await renderMarkdown(markdown, renderOnce) + + const frame = captureCharFrame() + expect(frame).toContain("Product history") + expect(frame).toContain("Foundation") + expect(frame).toContain("First release") + expect(frame).not.toContain("timeline") +}) diff --git a/packages/merman/src/timeline/diagram.test.ts b/packages/merman/src/timeline/diagram.test.ts new file mode 100644 index 0000000000..52e8ee2f8c --- /dev/null +++ b/packages/merman/src/timeline/diagram.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "bun:test" +import { renderTimelineDiagram } from "./diagram.js" +import { drawTimelineDiagramGrid } from "./drawing.js" +import { parseMermaidTimelineDiagram } from "./parser.js" +import { renderTimelineGridText } from "./render-grid.js" +import { resolveTimelineStyleColors } from "./style.js" + +describe("TimelineDiagram", () => { + test("detects and parses titles, sections, periods, inline events, and continuations", () => { + const diagram = parseMermaidTimelineDiagram(` +%% product history +timeline LR + title Product &
Platform + + section Foundation + 2024 : Prototype : First release + : Public beta + section Growth + 2025 : "Scale: ≥ 10k" +`) + + expect(diagram.direction).toBe("LR") + expect(diagram.title).toBe("Product &
Platform") + expect(diagram.sections).toEqual([{ label: "Foundation" }, { label: "Growth" }]) + expect(diagram.periods).toEqual([ + { period: "2024", events: ["Prototype", "First release", "Public beta"] }, + { period: "2025", events: ["Scale: ≥ 10k"] }, + ]) + expect(diagram.entries.map((entry) => entry.type)).toEqual(["section", "period", "section", "period"]) + }) + + test("renders a vertical spine with title, section, periods, events, entities, and line breaks", () => { + const output = renderTimelineDiagram(`timeline + title Product &
Platform + section Foundation
phase + 2024 : Prototype
ready : First release + : Scale ≥ 10k`) + + expect(output).toBe( + [ + " Product &", + " Platform", + "", + "Foundation ───┐", + " phase │", + " │", + " 2024 ───● Prototype", + " │ ready", + " │ First release", + " │ Scale ≥ 10k", + " │", + ].join("\n"), + ) + }) + + test.each(["timeline", "timeline TD", "timeline LR"])("uses the vertical terminal layout for %s", (header) => { + const output = renderTimelineDiagram(`${header}\n 2024 : One\n 2025 : Two`) + const lines = output.split("\n") + + expect(lines.findIndex((line) => line.includes("2024"))).toBeLessThan( + lines.findIndex((line) => line.includes("2025")), + ) + expect(output).toContain("│") + expect(output).toContain("●") + }) + + test("preserves Mermaid direction semantics while using vertical terminal layout", () => { + expect(parseMermaidTimelineDiagram("timeline\n 2024 : One").direction).toBe("LR") + expect(parseMermaidTimelineDiagram("timeline TD\n 2024 : One").direction).toBe("TD") + }) + + test("keeps ordinary colons in event text", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + 2024 : https://example.com : event:detail : next event`) + + expect(diagram.periods[0]?.events).toEqual(["https://example.com", "event:detail", "next event"]) + }) + + test("does not treat apostrophes in event prose as quotes", () => { + const diagram = parseMermaidTimelineDiagram("timeline\n 2024 : Kit's launch : Public beta") + + expect(diagram.periods[0]?.events).toEqual(["Kit's launch", "Public beta"]) + }) + + test("supports standalone periods followed by continuation events", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + 2024 + : First release + : Public beta`) + + expect(diagram.periods).toEqual([{ period: "2024", events: ["First release", "Public beta"] }]) + }) + + test("ignores timeline comments and accessibility directives", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + # product history + accTitle: Product timeline + accDescr Product release history + 2024 : Prototype %% internal note`) + + expect(diagram.periods).toEqual([{ period: "2024", events: ["Prototype"] }]) + }) + + test("ignores multiline accessibility descriptions", () => { + const diagram = parseMermaidTimelineDiagram(`timeline + accDescr { + Product milestones by year. + Includes launch and growth. + } + 2024 : Ship`) + + expect(diagram.periods).toEqual([{ period: "2024", events: ["Ship"] }]) + }) + + test("rejects a continuation without a period with source diagnostics", () => { + expect(() => parseMermaidTimelineDiagram("timeline\n : orphan event")).toThrow( + 'Timeline continuation requires a preceding period in timeline diagram at line 2: ": orphan event"', + ) + }) + + test("rejects unsupported and empty syntax", () => { + expect(() => parseMermaidTimelineDiagram("timeline\n section")).toThrow("Timeline section cannot be empty") + expect(() => parseMermaidTimelineDiagram("timeline\n 2024 :")).toThrow("Timeline event cannot be empty") + expect(() => parseMermaidTimelineDiagram("timeline\n : unsupported")).toThrow("requires a preceding period") + }) + + test("draws semantic styles for every timeline role", () => { + const grid = drawTimelineDiagramGrid( + parseMermaidTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"), + ) + const styles = new Set(grid.rows.flatMap((row) => row.map((cell) => cell.style).filter(Boolean))) + + expect(styles).toEqual( + new Set([ + "title", + "section", + "sectionFade1", + "sectionFade2", + "sectionFade3", + "spine", + "period", + "periodFade1", + "periodFade2", + "periodFade3", + "event", + ]), + ) + expect(Object.keys(resolveTimelineStyleColors()).sort()).toEqual([ + "event", + "period", + "periodFade1", + "periodFade2", + "periodFade3", + "section", + "sectionFade1", + "sectionFade2", + "sectionFade3", + "spine", + "title", + ]) + expect(renderTimelineGridText(grid)).toBe( + renderTimelineDiagram("timeline\n title Roadmap\n section Now\n 2026 : Ship"), + ) + }) + + test("uses section starts and joins with ordered color ramps", () => { + const grid = drawTimelineDiagramGrid( + parseMermaidTimelineDiagram("timeline\n section Morning\n 09:00 : Start\n section Midday\n 12:00 : Continue"), + ) + const text = renderTimelineGridText(grid) + + expect(text).toContain("Morning ───┐") + expect(text).toContain("Midday ───┤") + expect(grid.rows[0]?.map((cell) => cell.style).filter(Boolean)).toEqual([ + "section", + "section", + "section", + "section", + "section", + "section", + "section", + "sectionFade1", + "sectionFade2", + "sectionFade3", + "spine", + ]) + }) +}) diff --git a/packages/merman/src/timeline/diagram.ts b/packages/merman/src/timeline/diagram.ts new file mode 100644 index 0000000000..d8340bff5e --- /dev/null +++ b/packages/merman/src/timeline/diagram.ts @@ -0,0 +1,8 @@ +import { drawTimelineDiagramGrid } from "./drawing.js" +import { parseMermaidTimelineDiagram } from "./parser.js" +import { renderTimelineGridText } from "./render-grid.js" +import type { TimelineDiagramRenderOptions } from "./types.js" + +export function renderTimelineDiagram(content: string, options: TimelineDiagramRenderOptions = {}): string { + return renderTimelineGridText(drawTimelineDiagramGrid(parseMermaidTimelineDiagram(content), options)) +} diff --git a/packages/merman/src/timeline/drawing.ts b/packages/merman/src/timeline/drawing.ts new file mode 100644 index 0000000000..34fe704747 --- /dev/null +++ b/packages/merman/src/timeline/drawing.ts @@ -0,0 +1,109 @@ +import { DiagramCanvas } from "../core/canvas.js" +import { splitDiagramLines } from "../core/text-lines.js" +import { diagramTextWidth } from "../core/text.js" +import type { TimelineGrid } from "./render-grid.js" +import { TIMELINE_PERIOD_FADE_STYLES, TIMELINE_SECTION_FADE_STYLES } from "./style.js" +import type { TimelineCellStyle, TimelineDiagram, TimelineDiagramRenderOptions, TimelinePeriod } from "./types.js" + +interface PeriodLayout { + period: TimelinePeriod + periodLines: string[] + eventLines: string[][] + height: number +} + +const JOIN_WIDTH = TIMELINE_SECTION_FADE_STYLES.length +const SPINE_OFFSET = JOIN_WIDTH + 1 +const EVENT_OFFSET = 3 + +export function drawTimelineDiagramGrid( + diagram: TimelineDiagram, + _options: TimelineDiagramRenderOptions = {}, +): TimelineGrid { + const periodLayouts = new Map() + let leftWidth = 0 + let rightWidth = 0 + let bodyHeight = 0 + + for (const entry of diagram.entries) { + if (entry.type === "section") { + const lines = splitDiagramLines(entry.section.label) + bodyHeight += lines.length + 1 + for (const line of lines) leftWidth = Math.max(leftWidth, diagramTextWidth(line)) + continue + } + const periodLines = splitDiagramLines(entry.period.period) + const eventLines = entry.period.events.map(splitDiagramLines) + const eventHeight = eventLines.reduce((height, lines) => height + lines.length, 0) + const height = Math.max(periodLines.length, eventHeight) + periodLayouts.set(entry.period, { period: entry.period, periodLines, eventLines, height }) + for (const line of periodLines) leftWidth = Math.max(leftWidth, diagramTextWidth(line)) + for (const lines of eventLines) { + for (const line of lines) rightWidth = Math.max(rightWidth, diagramTextWidth(line)) + } + bodyHeight += height + 1 + } + + const titleLines = diagram.title ? splitDiagramLines(diagram.title) : [] + const bodyWidth = diagram.entries.length === 0 ? 0 : leftWidth + SPINE_OFFSET + EVENT_OFFSET + rightWidth + 1 + let titleWidth = 0 + for (const line of titleLines) titleWidth = Math.max(titleWidth, diagramTextWidth(line)) + const width = Math.max(bodyWidth, titleWidth) + const titleHeight = titleLines.length === 0 ? 0 : titleLines.length + (diagram.entries.length === 0 ? 0 : 1) + if (width === 0) return new DiagramCanvas(0, 0) + + const grid: TimelineGrid = new DiagramCanvas(width, titleHeight + bodyHeight) + titleLines.forEach((line, index) => + setText(grid, Math.floor((width - diagramTextWidth(line)) / 2), index, line, "title"), + ) + if (diagram.entries.length === 0) return grid + + const spineX = leftWidth + SPINE_OFFSET + let y = titleHeight + let railStarted = false + for (const entry of diagram.entries) { + if (entry.type === "section") { + const lines = splitDiagramLines(entry.section.label) + lines.forEach((line, index) => { + setText(grid, leftWidth - diagramTextWidth(line), y + index, line, "section") + if (index > 0) setCell(grid, spineX, y + index, "│", "spine") + }) + drawJoin(grid, leftWidth, y, TIMELINE_SECTION_FADE_STYLES) + setCell(grid, spineX, y, railStarted ? "┤" : "┐", "spine") + setCell(grid, spineX, y + lines.length, "│", "spine") + railStarted = true + y += lines.length + 1 + continue + } + + const layout = periodLayouts.get(entry.period)! + for (let row = 0; row < layout.height + 1; row++) setCell(grid, spineX, y + row, "│", "spine") + railStarted = true + setCell(grid, spineX, y, "●", "spine") + layout.periodLines.forEach((line, index) => { + const lineWidth = diagramTextWidth(line) + setText(grid, leftWidth - lineWidth, y + index, line, "period") + }) + drawJoin(grid, leftWidth, y, TIMELINE_PERIOD_FADE_STYLES) + + let eventY = y + for (const lines of layout.eventLines) { + lines.forEach((line, index) => setText(grid, spineX + EVENT_OFFSET, eventY + index, line, "event")) + eventY += lines.length + } + y += layout.height + 1 + } + return grid +} + +function drawJoin(grid: TimelineGrid, x: number, y: number, styles: readonly TimelineCellStyle[]): void { + styles.forEach((style, index) => setCell(grid, x + index + 1, y, "─", style)) +} + +function setCell(grid: TimelineGrid, x: number, y: number, char: string, style: TimelineCellStyle): void { + grid.setCell(x, y, char, style) +} + +function setText(grid: TimelineGrid, x: number, y: number, text: string, style: TimelineCellStyle): void { + grid.setText(x, y, text, style) +} diff --git a/packages/merman/src/timeline/parser.ts b/packages/merman/src/timeline/parser.ts new file mode 100644 index 0000000000..818d908557 --- /dev/null +++ b/packages/merman/src/timeline/parser.ts @@ -0,0 +1,119 @@ +import { firstMeaningfulMermaidLine, meaningfulNumberedMermaidLines, stripMermaidQuotes } from "../core/mermaid.js" +import { MermaidSyntaxError } from "../diagnostics.js" +import type { TimelineDiagram, TimelineDirection, TimelineEntry, TimelinePeriod, TimelineSection } from "./types.js" + +const HEADER_RE = /^timeline(?:\s+(TD|LR))?$/i +const TITLE_RE = /^title(?:\s+(.+))?$/i +const SECTION_RE = /^section(?:\s+(.+))?$/i +const ACCESSIBILITY_RE = /^acc(?:Title|Descr)(?::|\s|$)/i + +export function isMermaidTimelineDiagram(content: string): boolean { + return HEADER_RE.test(firstMeaningfulMermaidLine(content) ?? "") +} + +export function parseMermaidTimelineDiagram(content: string): TimelineDiagram { + const sections: TimelineSection[] = [] + const periods: TimelinePeriod[] = [] + const entries: TimelineEntry[] = [] + let direction: TimelineDirection = "LR" + let title: string | undefined + let currentPeriod: TimelinePeriod | undefined + let inAccessibilityDescription = false + + for (const source of meaningfulNumberedMermaidLines(content)) { + const line = stripTimelineComment(source.text) + if (inAccessibilityDescription) { + if (line === "}") inAccessibilityDescription = false + continue + } + if (/^accDescr\s*\{$/i.test(line)) { + inAccessibilityDescription = true + continue + } + if (!line || line.startsWith("#") || ACCESSIBILITY_RE.test(line)) continue + const header = line.match(HEADER_RE) + if (header) { + direction = (header[1]?.toUpperCase() as TimelineDirection | undefined) ?? "LR" + continue + } + + const titleMatch = line.match(TITLE_RE) + if (titleMatch) { + if (!titleMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline title cannot be empty") + title = stripMermaidQuotes(titleMatch[1]) + continue + } + + const sectionMatch = line.match(SECTION_RE) + if (sectionMatch) { + if (!sectionMatch[1]) throw syntaxError(source.lineNumber, line, "Timeline section cannot be empty") + const section = { label: stripMermaidQuotes(sectionMatch[1]) } + sections.push(section) + entries.push({ type: "section", section }) + currentPeriod = undefined + continue + } + + if (line.startsWith(":")) { + if (!currentPeriod) { + throw syntaxError(source.lineNumber, line, "Timeline continuation requires a preceding period") + } + currentPeriod.events.push(...parseEvents(line.slice(1), source.lineNumber, line)) + continue + } + + const fields = splitEventFields(line) + const periodLabel = stripMermaidQuotes(fields.shift()!) + if (!periodLabel) throw syntaxError(source.lineNumber, line, "Timeline period cannot be empty") + const period = { + period: periodLabel, + events: fields.length === 0 ? [] : parseEventFields(fields, source.lineNumber, line), + } + periods.push(period) + entries.push({ type: "period", period }) + currentPeriod = period + } + + return { direction, ...(title === undefined ? {} : { title }), sections, periods, entries } +} + +function parseEvents(value: string, lineNumber: number, sourceLine: string): string[] { + return parseEventFields(splitEventFields(value), lineNumber, sourceLine) +} + +function parseEventFields(fields: string[], lineNumber: number, sourceLine: string): string[] { + const events = fields.map(stripMermaidQuotes) + if (events.length === 0 || events.some((event) => event.length === 0)) { + throw syntaxError(lineNumber, sourceLine, "Timeline event cannot be empty") + } + return events +} + +function splitEventFields(value: string): string[] { + const fields: string[] = [] + let quote: '"' | "'" | undefined + let start = 0 + for (let index = 0; index < value.length; index++) { + const char = value[index] + if (char === '"' || char === "'") { + if (quote === char) quote = undefined + else if (quote === undefined && value.slice(start, index).trim() === "") quote = char + continue + } + const next = value[index + 1] + if (char !== ":" || quote !== undefined || (next !== undefined && !/\s/.test(next))) continue + fields.push(value.slice(start, index)) + start = index + 1 + } + fields.push(value.slice(start)) + return fields +} + +function stripTimelineComment(value: string): string { + const comment = value.indexOf("%%") + return (comment < 0 ? value : value.slice(0, comment)).trim() +} + +function syntaxError(lineNumber: number, sourceLine: string, reason?: string): MermaidSyntaxError { + return new MermaidSyntaxError("timeline", lineNumber, sourceLine, reason) +} diff --git a/packages/merman/src/timeline/render-grid.ts b/packages/merman/src/timeline/render-grid.ts new file mode 100644 index 0000000000..cb28a919a7 --- /dev/null +++ b/packages/merman/src/timeline/render-grid.ts @@ -0,0 +1,17 @@ +import type { StyledText } from "@opentui/core" +import type { DiagramCanvas } from "../core/canvas.js" +import { renderDiagramGridStyledText } from "../core/render-grid.js" +import type { TimelineStyleColors } from "./style.js" +import type { TimelineCellStyle } from "./types.js" + +export type TimelineGrid = DiagramCanvas + +export function renderTimelineGridText(grid: TimelineGrid): string { + return grid.toString({ trimBottom: true }) +} + +export function renderTimelineGridStyledText(grid: TimelineGrid, colors: TimelineStyleColors): StyledText { + return renderDiagramGridStyledText(grid, (run) => (run.style ? colors[run.style] : undefined), undefined, { + trimBottom: true, + }) +} diff --git a/packages/merman/src/timeline/style.ts b/packages/merman/src/timeline/style.ts new file mode 100644 index 0000000000..bea9c14318 --- /dev/null +++ b/packages/merman/src/timeline/style.ts @@ -0,0 +1,36 @@ +import { RGBA } from "@opentui/core" +import { blendColor, numberedStyleKeys, rgba, type DiagramRgb } from "../core/color/style.js" +import type { TimelineBaseCellStyle, TimelineCellStyle } from "./types.js" + +const DEFAULT_THEME_RGB = { + title: [228, 239, 232], + section: [154, 184, 169], + period: [230, 177, 126], + spine: [111, 138, 126], + event: [134, 225, 200], +} as const satisfies Record + +export type TimelineStyleColors = Required> +export const TIMELINE_SECTION_FADE_STYLES = numberedStyleKeys("sectionFade", [1, 2, 3] as const) +export const TIMELINE_PERIOD_FADE_STYLES = numberedStyleKeys("periodFade", [1, 2, 3] as const) + +export function resolveTimelineStyleColors( + colors: Partial> = {}, +): TimelineStyleColors { + const section = colors.section ?? rgba(DEFAULT_THEME_RGB.section) + const period = colors.period ?? rgba(DEFAULT_THEME_RGB.period) + const spine = colors.spine ?? rgba(DEFAULT_THEME_RGB.spine) + return { + title: colors.title ?? rgba(DEFAULT_THEME_RGB.title), + section, + period, + spine, + event: colors.event ?? rgba(DEFAULT_THEME_RGB.event), + sectionFade1: blendColor(section, spine, 0.5), + sectionFade2: blendColor(section, spine, 0.67), + sectionFade3: blendColor(section, spine, 0.83), + periodFade1: blendColor(period, spine, 0.5), + periodFade2: blendColor(period, spine, 0.67), + periodFade3: blendColor(period, spine, 0.83), + } +} diff --git a/packages/merman/src/timeline/types.ts b/packages/merman/src/timeline/types.ts new file mode 100644 index 0000000000..fc7c3ab108 --- /dev/null +++ b/packages/merman/src/timeline/types.ts @@ -0,0 +1,31 @@ +export type TimelineDirection = "TD" | "LR" + +export interface TimelineSection { + label: string +} + +export interface TimelinePeriod { + period: string + events: string[] +} + +export type TimelineEntry = { type: "section"; section: TimelineSection } | { type: "period"; period: TimelinePeriod } + +export interface TimelineDiagram { + direction: TimelineDirection + title?: string + sections: TimelineSection[] + periods: TimelinePeriod[] + entries: TimelineEntry[] +} + +export interface TimelineDiagramRenderOptions { + /** Parsed for Mermaid compatibility. Timeline diagrams always use a vertical terminal layout. */ + direction?: TimelineDirection +} + +export type TimelineBaseCellStyle = "title" | "section" | "period" | "spine" | "event" +export type TimelineFadeStep = 1 | 2 | 3 +export type TimelineSectionFadeStyle = `sectionFade${TimelineFadeStep}` +export type TimelinePeriodFadeStyle = `periodFade${TimelineFadeStep}` +export type TimelineCellStyle = TimelineBaseCellStyle | TimelineSectionFadeStyle | TimelinePeriodFadeStyle