@diagrammo/dgmo 0.8.19 → 0.8.20

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.
@@ -0,0 +1,213 @@
1
+ // ============================================================
2
+ // Time axis tick computation — shared by d3.ts and gantt/renderer.ts
3
+ // ============================================================
4
+
5
+ import * as d3Scale from 'd3-scale';
6
+
7
+ export const MONTH_ABBR = [
8
+ 'Jan',
9
+ 'Feb',
10
+ 'Mar',
11
+ 'Apr',
12
+ 'May',
13
+ 'Jun',
14
+ 'Jul',
15
+ 'Aug',
16
+ 'Sep',
17
+ 'Oct',
18
+ 'Nov',
19
+ 'Dec',
20
+ ];
21
+
22
+ function fractionalYearToDate(frac: number): Date {
23
+ const year = Math.floor(frac);
24
+ const remainder = frac - year;
25
+ // Inverse of: (month-1)/12 + (day-1)/365 + hour/8760 + minute/525600
26
+ const monthFrac = remainder * 12;
27
+ const month = Math.floor(monthFrac); // 0-based
28
+ const monthRemainder = remainder - month / 12;
29
+ const dayFrac = monthRemainder * 365; // fractional day-of-year offset
30
+ const day = Math.floor(dayFrac) + 1;
31
+ const dayRemainder = dayFrac - Math.floor(dayFrac);
32
+ const hourFrac = dayRemainder * 24;
33
+ const hour = Math.floor(hourFrac);
34
+ const minute = Math.round((hourFrac - hour) * 60);
35
+ return new Date(year, month, day, hour, minute);
36
+ }
37
+
38
+ /** Convert a Date to a fractional year number. */
39
+ function dateToFractionalYear(d: Date): number {
40
+ return (
41
+ d.getFullYear() +
42
+ d.getMonth() / 12 +
43
+ (d.getDate() - 1) / 365 +
44
+ d.getHours() / 8760 +
45
+ d.getMinutes() / 525600
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Generates adaptive tick marks along a time axis.
51
+ * Picks the right granularity (years, months, weeks, days, hours, minutes)
52
+ * based on the domain span.
53
+ *
54
+ * Optional boundary parameters add ticks at exact data start/end:
55
+ * - boundaryStart/boundaryEnd: numeric date values
56
+ * - boundaryStartLabel/boundaryEndLabel: formatted labels for those dates
57
+ */
58
+ export function computeTimeTicks(
59
+ domainMin: number,
60
+ domainMax: number,
61
+ scale: d3Scale.ScaleLinear<number, number>,
62
+ boundaryStart?: number,
63
+ boundaryEnd?: number,
64
+ boundaryStartLabel?: string,
65
+ boundaryEndLabel?: string
66
+ ): { pos: number; label: string }[] {
67
+ const minYear = Math.floor(domainMin);
68
+ const maxYear = Math.floor(domainMax);
69
+ const span = domainMax - domainMin;
70
+
71
+ let ticks: { pos: number; label: string }[] = [];
72
+
73
+ // Year ticks for multi-year spans (need at least 2 boundaries)
74
+ const firstYear = Math.ceil(domainMin);
75
+ const lastYear = Math.floor(domainMax);
76
+ if (lastYear >= firstYear + 1) {
77
+ // Decimate ticks for long spans so labels don't overlap
78
+ const yearSpan = lastYear - firstYear;
79
+ let step = 1;
80
+ if (yearSpan > 80) step = 20;
81
+ else if (yearSpan > 40) step = 10;
82
+ else if (yearSpan > 20) step = 5;
83
+ else if (yearSpan > 10) step = 2;
84
+
85
+ // Align to step boundary so ticks land on round years (1700, 1710, …)
86
+ const alignedFirst = Math.ceil(firstYear / step) * step;
87
+ for (let y = alignedFirst; y <= lastYear; y += step) {
88
+ ticks.push({ pos: scale(y), label: String(y) });
89
+ }
90
+ } else if (span > 0.25) {
91
+ // Month ticks for spans > ~3 months
92
+ const crossesYear = maxYear > minYear;
93
+ for (let y = minYear; y <= maxYear + 1; y++) {
94
+ for (let m = 1; m <= 12; m++) {
95
+ const val = y + (m - 1) / 12;
96
+ if (val > domainMax) break;
97
+ if (val >= domainMin) {
98
+ ticks.push({
99
+ pos: scale(val),
100
+ label: crossesYear
101
+ ? `${MONTH_ABBR[m - 1]} '${String(y).slice(-2)}`
102
+ : MONTH_ABBR[m - 1],
103
+ });
104
+ }
105
+ }
106
+ }
107
+ } else if (span <= 0.000685) {
108
+ // Minute ticks for spans ≤ ~6 hours
109
+ // Adaptive step: >3h → 30min, >1h → 15min, >30min → 10min, else 5min
110
+ let stepMin = 5;
111
+ const spanHours = span * 8760;
112
+ if (spanHours > 3) stepMin = 30;
113
+ else if (spanHours > 1) stepMin = 15;
114
+ else if (spanHours > 0.5) stepMin = 10;
115
+
116
+ // Iterate from the start hour boundary
117
+ const startDate = fractionalYearToDate(domainMin);
118
+ // Round down to nearest step boundary
119
+ startDate.setMinutes(
120
+ Math.floor(startDate.getMinutes() / stepMin) * stepMin,
121
+ 0,
122
+ 0
123
+ );
124
+
125
+ while (true) {
126
+ const val = dateToFractionalYear(startDate);
127
+ if (val > domainMax) break;
128
+ if (val >= domainMin) {
129
+ const hh = String(startDate.getHours()).padStart(2, '0');
130
+ const mm = String(startDate.getMinutes()).padStart(2, '0');
131
+ ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
132
+ }
133
+ startDate.setMinutes(startDate.getMinutes() + stepMin);
134
+ }
135
+ } else if (span <= 0.00822) {
136
+ // Hour ticks for spans ≤ ~3 days
137
+ // Adaptive step: >2d → 6h, >1d → 3h, >12h → 2h, else 1h
138
+ let stepHour = 1;
139
+ const spanHours = span * 8760;
140
+ if (spanHours > 48) stepHour = 6;
141
+ else if (spanHours > 24) stepHour = 3;
142
+ else if (spanHours > 12) stepHour = 2;
143
+
144
+ // For single-day spans, just show HH:MM without the date prefix
145
+ const singleDay = spanHours <= 24;
146
+
147
+ const startDate = fractionalYearToDate(domainMin);
148
+ // Round down to nearest step boundary
149
+ startDate.setHours(
150
+ Math.floor(startDate.getHours() / stepHour) * stepHour,
151
+ 0,
152
+ 0,
153
+ 0
154
+ );
155
+
156
+ while (true) {
157
+ const val = dateToFractionalYear(startDate);
158
+ if (val > domainMax) break;
159
+ if (val >= domainMin) {
160
+ const hh = String(startDate.getHours()).padStart(2, '0');
161
+ const mm = String(startDate.getMinutes()).padStart(2, '0');
162
+ if (singleDay) {
163
+ ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
164
+ } else {
165
+ const mon = MONTH_ABBR[startDate.getMonth()];
166
+ const d = startDate.getDate();
167
+ ticks.push({ pos: scale(val), label: `${mon} ${d} ${hh}:${mm}` });
168
+ }
169
+ }
170
+ startDate.setHours(startDate.getHours() + stepHour);
171
+ }
172
+ } else {
173
+ // Week ticks for spans ≤ ~3 months (1st, 8th, 15th, 22nd of each month)
174
+ for (let y = minYear; y <= maxYear + 1; y++) {
175
+ for (let m = 1; m <= 12; m++) {
176
+ for (const d of [1, 8, 15, 22]) {
177
+ const val = y + (m - 1) / 12 + (d - 1) / 365;
178
+ if (val > domainMax) break;
179
+ if (val >= domainMin) {
180
+ ticks.push({
181
+ pos: scale(val),
182
+ label: `${MONTH_ABBR[m - 1]} ${d}`,
183
+ });
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ // Add boundary ticks at exact data start/end if provided
191
+ // When a boundary tick collides with a standard tick, replace the standard tick
192
+ const collisionThreshold = 40; // pixels
193
+
194
+ if (boundaryStart !== undefined && boundaryStartLabel) {
195
+ const boundaryPos = scale(boundaryStart);
196
+ // Remove any standard ticks that would collide with the start boundary
197
+ ticks = ticks.filter(
198
+ (t) => Math.abs(t.pos - boundaryPos) >= collisionThreshold
199
+ );
200
+ ticks.unshift({ pos: boundaryPos, label: boundaryStartLabel });
201
+ }
202
+
203
+ if (boundaryEnd !== undefined && boundaryEndLabel) {
204
+ const boundaryPos = scale(boundaryEnd);
205
+ // Remove any standard ticks that would collide with the end boundary
206
+ ticks = ticks.filter(
207
+ (t) => Math.abs(t.pos - boundaryPos) >= collisionThreshold
208
+ );
209
+ ticks.push({ pos: boundaryPos, label: boundaryEndLabel });
210
+ }
211
+
212
+ return ticks;
213
+ }
package/src/branding.ts DELETED
@@ -1,67 +0,0 @@
1
- import { FONT_FAMILY } from './fonts';
2
-
3
- const BRANDING_HEIGHT = 20;
4
-
5
- /**
6
- * Injects `diagrammo.app` branding text into an SVG string.
7
- * Extends the SVG height by 20px and places the text at the bottom-right.
8
- */
9
- export function injectBranding(svgHtml: string, mutedColor: string): string {
10
- if (!svgHtml) return svgHtml;
11
-
12
- const brandingText = `<text x="0" y="0" font-size="10" font-family="${FONT_FAMILY}" fill="${mutedColor}" opacity="0.5" text-anchor="end">diagrammo.app</text>`;
13
-
14
- // Parse viewBox
15
- const vbMatch = svgHtml.match(/viewBox="([^"]+)"/);
16
- const heightAttrMatch = svgHtml.match(/height="([^"]+)"/);
17
-
18
- if (vbMatch) {
19
- const parts = vbMatch[1].split(/\s+/).map(Number);
20
- if (parts.length === 4) {
21
- const [vx, vy, vw, vh] = parts;
22
- const newVh = vh + BRANDING_HEIGHT;
23
- const textX = vx + vw - 4;
24
- const textY = vy + vh + BRANDING_HEIGHT - 6;
25
- const positioned = brandingText.replace('x="0" y="0"', `x="${textX}" y="${textY}"`);
26
-
27
- let result = svgHtml.replace(
28
- /viewBox="[^"]+"/,
29
- `viewBox="${vx} ${vy} ${vw} ${newVh}"`
30
- );
31
-
32
- // Update height attribute if present
33
- if (heightAttrMatch) {
34
- const oldH = parseFloat(heightAttrMatch[1]);
35
- if (!isNaN(oldH)) {
36
- result = result.replace(
37
- `height="${heightAttrMatch[1]}"`,
38
- `height="${oldH + BRANDING_HEIGHT}"`
39
- );
40
- }
41
- }
42
-
43
- result = result.replace('</svg>', `${positioned}</svg>`);
44
- return result;
45
- }
46
- }
47
-
48
- // Fallback: no viewBox, try width/height attributes
49
- if (heightAttrMatch) {
50
- const widthMatch = svgHtml.match(/width="([^"]+)"/);
51
- const w = widthMatch ? parseFloat(widthMatch[1]) : 800;
52
- const h = parseFloat(heightAttrMatch[1]);
53
- if (!isNaN(h) && !isNaN(w)) {
54
- const textX = w - 4;
55
- const textY = h + BRANDING_HEIGHT - 6;
56
- const positioned = brandingText.replace('x="0" y="0"', `x="${textX}" y="${textY}"`);
57
- let result = svgHtml.replace(
58
- `height="${heightAttrMatch[1]}"`,
59
- `height="${h + BRANDING_HEIGHT}"`
60
- );
61
- result = result.replace('</svg>', `${positioned}</svg>`);
62
- return result;
63
- }
64
- }
65
-
66
- return svgHtml;
67
- }
@@ -1,262 +0,0 @@
1
- // ============================================================
2
- // .dgmo → Mermaid Translation Layer
3
- // Parses dgmo quadrant syntax and generates valid Mermaid code.
4
- // ============================================================
5
-
6
- import { resolveColorWithDiagnostic } from './colors';
7
- import type { DgmoError } from './diagnostics';
8
- import { makeDgmoError, formatDgmoError } from './diagnostics';
9
-
10
- // ============================================================
11
- // Types
12
- // ============================================================
13
-
14
- interface QuadrantLabel {
15
- text: string;
16
- color: string | null;
17
- lineNumber: number;
18
- }
19
-
20
- export interface ParsedQuadrant {
21
- title: string | null;
22
- titleLineNumber: number | null;
23
- xAxis: [string, string] | null;
24
- xAxisLineNumber: number | null;
25
- yAxis: [string, string] | null;
26
- yAxisLineNumber: number | null;
27
- quadrants: {
28
- topRight: QuadrantLabel | null;
29
- topLeft: QuadrantLabel | null;
30
- bottomLeft: QuadrantLabel | null;
31
- bottomRight: QuadrantLabel | null;
32
- };
33
- points: { label: string; x: number; y: number; lineNumber: number }[];
34
- diagnostics: DgmoError[];
35
- error: string | null;
36
- }
37
-
38
- // ============================================================
39
- // Parser
40
- // ============================================================
41
-
42
- /** Regex for quadrant label lines: `top-right Promote (green)` */
43
- const QUADRANT_LABEL_RE = /^(.+?)(?:\s*\(([^)]+)\))?\s*$/;
44
-
45
- /** Regex for data point lines: `Label 0.9, 0.5` */
46
- const DATA_POINT_RE = /^(.+?)\s+([0-9]*\.?[0-9]+)\s*,\s*([0-9]*\.?[0-9]+)\s*$/;
47
-
48
- const QUADRANT_POSITIONS = new Set([
49
- 'top-right',
50
- 'top-left',
51
- 'bottom-left',
52
- 'bottom-right',
53
- ]);
54
-
55
- /**
56
- * Parses a .dgmo quadrant document into a structured object.
57
- * Lines are processed sequentially; unknown lines are silently skipped.
58
- */
59
- export function parseQuadrant(content: string): ParsedQuadrant {
60
- const result: ParsedQuadrant = {
61
- title: null,
62
- titleLineNumber: null,
63
- xAxis: null,
64
- xAxisLineNumber: null,
65
- yAxis: null,
66
- yAxisLineNumber: null,
67
- quadrants: {
68
- topRight: null,
69
- topLeft: null,
70
- bottomLeft: null,
71
- bottomRight: null,
72
- },
73
- points: [],
74
- diagnostics: [],
75
- error: null,
76
- };
77
-
78
- const lines = content.split('\n');
79
-
80
- for (let i = 0; i < lines.length; i++) {
81
- const line = lines[i].trim();
82
- const lineNumber = i + 1; // 1-indexed for editor
83
-
84
- // Skip empty lines and comments
85
- if (!line || line.startsWith('//')) continue;
86
-
87
- // Skip the chart: directive (already consumed by router)
88
- if (/^chart\s*:/i.test(line)) continue;
89
-
90
- // title <text>
91
- const titleMatch = line.match(/^title\s+(.+)/i);
92
- if (titleMatch) {
93
- result.title = titleMatch[1].trim();
94
- result.titleLineNumber = lineNumber;
95
- continue;
96
- }
97
-
98
- // x-label Low, High
99
- const xMatch = line.match(/^x-label\s+(.+)/i);
100
- if (xMatch) {
101
- const parts = xMatch[1].split(',').map((s) => s.trim());
102
- if (parts.length >= 2) {
103
- result.xAxis = [parts[0], parts[1]];
104
- result.xAxisLineNumber = lineNumber;
105
- }
106
- continue;
107
- }
108
-
109
- // y-label Low, High
110
- const yMatch = line.match(/^y-label\s+(.+)/i);
111
- if (yMatch) {
112
- const parts = yMatch[1].split(',').map((s) => s.trim());
113
- if (parts.length >= 2) {
114
- result.yAxis = [parts[0], parts[1]];
115
- result.yAxisLineNumber = lineNumber;
116
- }
117
- continue;
118
- }
119
-
120
- // Quadrant position labels: top-right Label (color)
121
- const posMatch = line.match(
122
- /^(top-right|top-left|bottom-left|bottom-right)\s+(.+)/i
123
- );
124
- if (posMatch) {
125
- const position = posMatch[1].toLowerCase();
126
- const labelMatch = posMatch[2].match(QUADRANT_LABEL_RE);
127
- if (labelMatch) {
128
- const label: QuadrantLabel = {
129
- text: labelMatch[1].trim(),
130
- color: labelMatch[2]
131
- ? (resolveColorWithDiagnostic(
132
- labelMatch[2].trim(),
133
- lineNumber,
134
- result.diagnostics
135
- ) ?? null)
136
- : null,
137
- lineNumber,
138
- };
139
- if (position === 'top-right') result.quadrants.topRight = label;
140
- else if (position === 'top-left') result.quadrants.topLeft = label;
141
- else if (position === 'bottom-left')
142
- result.quadrants.bottomLeft = label;
143
- else if (position === 'bottom-right')
144
- result.quadrants.bottomRight = label;
145
- }
146
- continue;
147
- }
148
-
149
- // Data points: Label x, y
150
- const pointMatch = line.match(DATA_POINT_RE);
151
- if (pointMatch) {
152
- // Make sure this isn't a quadrant position keyword
153
- const key = pointMatch[1].trim().toLowerCase();
154
- if (!QUADRANT_POSITIONS.has(key)) {
155
- result.points.push({
156
- label: pointMatch[1].trim(),
157
- x: parseFloat(pointMatch[2]),
158
- y: parseFloat(pointMatch[3]),
159
- lineNumber,
160
- });
161
- }
162
- continue;
163
- }
164
- }
165
-
166
- if (result.points.length === 0) {
167
- const diag = makeDgmoError(
168
- 1,
169
- 'No data points found. Add lines like: Label 0.5, 0.7'
170
- );
171
- result.diagnostics.push(diag);
172
- result.error = formatDgmoError(diag);
173
- }
174
-
175
- return result;
176
- }
177
-
178
- // ============================================================
179
- // Mermaid Builder
180
- // ============================================================
181
-
182
- /**
183
- * Generates valid Mermaid quadrantChart syntax from a parsed quadrant.
184
- * Returns a string ready for the Mermaid renderer.
185
- */
186
- export function buildMermaidQuadrant(
187
- parsed: ParsedQuadrant,
188
- options: {
189
- isDark?: boolean;
190
- textColor?: string;
191
- mutedTextColor?: string;
192
- } = {}
193
- ): string {
194
- const { isDark = false, textColor, mutedTextColor } = options;
195
- const lines: string[] = [];
196
-
197
- // %%{init}%% block — fill colors with reduced opacity + text color overrides
198
- const fillAlpha = isDark ? '30' : '55';
199
- const primaryText = textColor ?? (isDark ? '#d0d0d0' : '#333333');
200
- const quadrantLabelText = mutedTextColor ?? (isDark ? '#888888' : '#666666');
201
-
202
- const colorMap: Record<string, string> = {};
203
- if (parsed.quadrants.topRight?.color)
204
- colorMap.quadrant1Fill = parsed.quadrants.topRight.color + fillAlpha;
205
- if (parsed.quadrants.topLeft?.color)
206
- colorMap.quadrant2Fill = parsed.quadrants.topLeft.color + fillAlpha;
207
- if (parsed.quadrants.bottomLeft?.color)
208
- colorMap.quadrant3Fill = parsed.quadrants.bottomLeft.color + fillAlpha;
209
- if (parsed.quadrants.bottomRight?.color)
210
- colorMap.quadrant4Fill = parsed.quadrants.bottomRight.color + fillAlpha;
211
-
212
- // Quadrant labels use muted color, points use primary text color
213
- colorMap.quadrant1TextFill = quadrantLabelText;
214
- colorMap.quadrant2TextFill = quadrantLabelText;
215
- colorMap.quadrant3TextFill = quadrantLabelText;
216
- colorMap.quadrant4TextFill = quadrantLabelText;
217
- colorMap.quadrantPointTextFill = primaryText;
218
- colorMap.quadrantXAxisTextFill = primaryText;
219
- colorMap.quadrantYAxisTextFill = primaryText;
220
- colorMap.quadrantTitleFill = primaryText;
221
-
222
- const vars = JSON.stringify(colorMap);
223
- lines.push(`%%{init: {"themeVariables": ${vars}}}%%`);
224
-
225
- lines.push('quadrantChart');
226
-
227
- if (parsed.title) {
228
- lines.push(` title ${parsed.title}`);
229
- }
230
-
231
- if (parsed.xAxis) {
232
- lines.push(` x-axis ${parsed.xAxis[0]} --> ${parsed.xAxis[1]}`);
233
- }
234
-
235
- if (parsed.yAxis) {
236
- lines.push(` y-axis ${parsed.yAxis[0]} --> ${parsed.yAxis[1]}`);
237
- }
238
-
239
- // Helper to quote labels that need it (contain spaces or special chars)
240
- const quote = (s: string): string => (/[\s,:[\]]/.test(s) ? `"${s}"` : s);
241
-
242
- // Quadrant labels: 1=top-right, 2=top-left, 3=bottom-left, 4=bottom-right
243
- if (parsed.quadrants.topRight) {
244
- lines.push(` quadrant-1 ${quote(parsed.quadrants.topRight.text)}`);
245
- }
246
- if (parsed.quadrants.topLeft) {
247
- lines.push(` quadrant-2 ${quote(parsed.quadrants.topLeft.text)}`);
248
- }
249
- if (parsed.quadrants.bottomLeft) {
250
- lines.push(` quadrant-3 ${quote(parsed.quadrants.bottomLeft.text)}`);
251
- }
252
- if (parsed.quadrants.bottomRight) {
253
- lines.push(` quadrant-4 ${quote(parsed.quadrants.bottomRight.text)}`);
254
- }
255
-
256
- // Data points
257
- for (const point of parsed.points) {
258
- lines.push(` ${quote(point.label)}: [${point.x}, ${point.y}]`);
259
- }
260
-
261
- return lines.join('\n');
262
- }