@taylorwong/ichartjs 2.0.1 → 2.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taylorwong/ichartjs",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Agent-first, renderer-independent charting and project visualization runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
package/src/charts.mjs CHANGED
@@ -139,7 +139,7 @@ function addProjectScene(scene, spec, data, state, colors) {
139
139
  return;
140
140
  }
141
141
  if (spec.type === 'burndown') {
142
- const rows = projectRows(spec, data), dates = rows.map(row => new Date(row.date).getTime()).filter(Number.isFinite), min = Math.min(...(dates.length ? dates : [Date.now()])), max = Math.max(...(dates.length ? dates : [min + 86400000])), values = rows.map(row => Number(row.remaining ?? row.actual ?? row.value)).filter(Number.isFinite), maxValue = Math.max(1, ...values, ...rows.map(row => Number(row.ideal)).filter(Number.isFinite)); addProjectAxes(scene, plot, min, max); const point = row => ({ x: projectDate(row.date, min, max, plot), y: plot.y + plot.height - (Number(row.remaining ?? row.actual ?? row.value) / maxValue) * plot.height }); const actual = rows.map(point).filter(point => Number.isFinite(point.y)); if (actual.length > 1) scene.add(new SceneNode({ id: 'burndown-actual', type: 'path', geometry: { points: actual }, style: { stroke: colors[0], strokeWidth: 2 } })); const ideal = rows.map((row, index) => ({ x: projectDate(row.date, min, max, plot), y: plot.y + plot.height - (Number(row.ideal ?? maxValue * (1 - index / Math.max(1, rows.length - 1))) / maxValue) * plot.height })); if (ideal.length > 1) scene.add(new SceneNode({ id: 'burndown-ideal', type: 'path', geometry: { points: ideal }, style: { stroke: '#94a3b8', strokeWidth: 1, opacity: 0.8 } })); rows.forEach((row, index) => { if (index && Number(row.scopeChange)) { const x = projectDate(row.date, min, max, plot); scene.add(new SceneNode({ id: `burndown-scope-${index}`, type: 'line', geometry: { x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height }, style: { stroke: '#f59e0b', strokeWidth: 1.5, opacity: 0.8 }, dataRef: { dataIndex: index, field: 'scopeChange', datum: row } })); } }); actual.forEach((point, index) => scene.add(new SceneNode({ id: `burndown-item-${index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[0] }, dataRef: { dataIndex: index, field: 'remaining', datum: rows[index] }, interactive: true }))); const last = actual.at(-1), lastRow = rows.at(-1); if (last && lastRow && Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) > 0 && rows.length > 1) { const slope = (Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) - Number(rows[0].remaining ?? rows[0].actual ?? rows[0].value)) / Math.max(1, dates.length - 1), projected = slope < 0 ? new Date(new Date(lastRow.date).getTime() + Math.ceil(Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) / -slope) * 86400000) : null; if (projected) addText(scene, 'burndown-projected', `Projected ${formatTime(projected, max - min)}`, Math.min(plot.x + plot.width, last.x + 12), plot.y + 18, { fill: '#dc2626', font: '12px system-ui' }); } return;
142
+ const rows = projectRows(spec, data), dates = rows.map(row => new Date(row.date).getTime()).filter(Number.isFinite), min = Math.min(...(dates.length ? dates : [Date.now()])), max = Math.max(...(dates.length ? dates : [min + 86400000])), values = rows.map(row => Number(row.remaining ?? row.actual ?? row.value)).filter(Number.isFinite), maxValue = Math.max(1, ...values, ...rows.map(row => Number(row.ideal)).filter(Number.isFinite)); addProjectAxes(scene, plot, min, max); const point = row => ({ x: projectDate(row.date, min, max, plot), y: plot.y + plot.height - (Number(row.remaining ?? row.actual ?? row.value) / maxValue) * plot.height }); const actual = rows.map(point).filter(point => Number.isFinite(point.y)); if (actual.length > 1) scene.add(new SceneNode({ id: 'burndown-actual', type: 'path', geometry: { points: actual, closed: false }, style: { stroke: colors[0], strokeWidth: 2 } })); const ideal = rows.map((row, index) => ({ x: projectDate(row.date, min, max, plot), y: plot.y + plot.height - (Number(row.ideal ?? maxValue * (1 - index / Math.max(1, rows.length - 1))) / maxValue) * plot.height })); if (ideal.length > 1) scene.add(new SceneNode({ id: 'burndown-ideal', type: 'path', geometry: { points: ideal, closed: false }, style: { stroke: '#94a3b8', strokeWidth: 1, opacity: 0.8 } })); rows.forEach((row, index) => { if (index && Number(row.scopeChange)) { const x = projectDate(row.date, min, max, plot); scene.add(new SceneNode({ id: `burndown-scope-${index}`, type: 'line', geometry: { x1: x, y1: plot.y, x2: x, y2: plot.y + plot.height }, style: { stroke: '#f59e0b', strokeWidth: 1.5, opacity: 0.8 }, dataRef: { dataIndex: index, field: 'scopeChange', datum: row } })); } }); actual.forEach((point, index) => scene.add(new SceneNode({ id: `burndown-item-${index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[0] }, dataRef: { dataIndex: index, field: 'remaining', datum: rows[index] }, interactive: true }))); const last = actual.at(-1), lastRow = rows.at(-1); if (last && lastRow && Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) > 0 && rows.length > 1) { const slope = (Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) - Number(rows[0].remaining ?? rows[0].actual ?? rows[0].value)) / Math.max(1, dates.length - 1), projected = slope < 0 ? new Date(new Date(lastRow.date).getTime() + Math.ceil(Number(lastRow.remaining ?? lastRow.actual ?? lastRow.value) / -slope) * 86400000) : null; if (projected) addText(scene, 'burndown-projected', `Projected ${formatTime(projected, max - min)}`, Math.min(plot.x + plot.width, last.x + 12), plot.y + 18, { fill: '#dc2626', font: '12px system-ui' }); } return;
143
143
  }
144
144
  if (['flow', 'swimlane'].includes(spec.type)) {
145
145
  const nodes = spec.nodes || spec.data.nodes || [], edges = spec.edges || spec.data.edges || [], lanes = spec.lanes || spec.data.lanes || [], laneHeight = plot.height / Math.max(1, lanes.length || 1); const nodeMap = new Map(); nodes.forEach((node, index) => { const laneIndex = lanes.length ? Math.max(0, lanes.findIndex(lane => lane.id === node.laneId)) : 0, columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length))), column = index % columns, row = Math.floor(index / columns), x = plot.x + (column + 0.5) * plot.width / columns, y = lanes.length ? plot.y + laneIndex * laneHeight + laneHeight / 2 : plot.y + (row + 0.5) * plot.height / Math.max(1, Math.ceil(nodes.length / columns)); nodeMap.set(node.id, { ...node, x, y }); if (lanes.length) { scene.add(new SceneNode({ id: `lane-${laneIndex}`, type: 'rect', geometry: { x: plot.x, y: plot.y + laneIndex * laneHeight, width: plot.width, height: laneHeight }, style: { fill: laneIndex % 2 ? '#f8fafc' : '#ffffff', stroke: '#e2e8f0', strokeWidth: 1 }, interactive: false })); addText(scene, `lane-label-${laneIndex}`, lanes[laneIndex].label || lanes[laneIndex].id, plot.x - 8, plot.y + laneIndex * laneHeight + 18, { fill: '#475569', font: '12px system-ui', textAnchor: 'end' }); } }); edges.forEach((edge, index) => { const from = nodeMap.get(edge.from), to = nodeMap.get(edge.to); if (!from || !to) return; scene.add(new SceneNode({ id: `edge-${index}`, type: 'line', geometry: { x1: from.x, y1: from.y, x2: to.x, y2: to.y }, style: { stroke: '#64748b', strokeWidth: 1.5 }, dataRef: { edgeIndex: index, from: edge.from, to: edge.to } })); }); nodeMap.forEach((node, id) => { const geometry = { x: node.x - 48, y: node.y - 18, width: 96, height: 36 }; scene.add(new SceneNode({ id: `node-${id}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[0], stroke: '#ffffff', strokeWidth: 1 }, dataRef: { nodeId: id, field: 'node' }, interactive: true })); addText(scene, `node-label-${id}`, node.label || id, node.x, node.y + 4, { fill: '#ffffff', font: '12px system-ui', textAnchor: 'middle' }); });
@@ -208,12 +208,12 @@ export function buildScene(spec) {
208
208
  else if (spec.type === 'radar') { addRadarScene(scene, spec, data, state, colors); addLegend(scene, spec, data.rows.map((row, index) => ({ name: row.name || row.id || `Series ${index + 1}`, color: colors[index % colors.length] }))); }
209
209
  else if (spec.type === 'line' || spec.type === 'area') {
210
210
  const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
211
- series.forEach((encoding, seriesIndex) => { const yMap = seriesIndex === 1 && !mode ? state.yRight : state.y, values = data.rows.map(row => Number(row[encoding.field])), totals = data.rows.map((row, index) => series.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || Math.abs(values[index]) || 1); const points = [], bases = []; data.rows.forEach((row, index) => { let value = values[index]; if (!Number.isFinite(value)) return; if (mode === 'percent') value /= totals[index]; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = mode ? start + value : value; if (mode) accumulators[index][accumulator] = end; points.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(end), row, index, value }); bases.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(start) }); }); if (spec.type === 'area' && points.length) scene.add(new SceneNode({ id: `area-fill-${seriesIndex}`, type: 'path', geometry: { points: [...bases.slice().reverse(), ...points.map(point => ({ x: point.x, y: point.y }))], closed: true }, style: { fill: `${colors[seriesIndex]}33`, stroke: 'transparent' } })); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points }, style: { stroke: colors[seriesIndex], strokeWidth: 2, fill: 'none' } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 8, dataRef); }); });
211
+ series.forEach((encoding, seriesIndex) => { const yMap = seriesIndex === 1 && !mode ? state.yRight : state.y, values = data.rows.map(row => Number(row[encoding.field])), totals = data.rows.map((row, index) => series.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || Math.abs(values[index]) || 1); const points = [], bases = []; data.rows.forEach((row, index) => { let value = values[index]; if (!Number.isFinite(value)) return; if (mode === 'percent') value /= totals[index]; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = mode ? start + value : value; if (mode) accumulators[index][accumulator] = end; points.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(end), row, index, value }); bases.push({ x: state.x(pick(row, spec.encoding.x, 'name')), y: yMap(start) }); }); if (spec.type === 'area' && points.length) scene.add(new SceneNode({ id: `area-fill-${seriesIndex}`, type: 'path', geometry: { points: [...bases.slice().reverse(), ...points.map(point => ({ x: point.x, y: point.y }))], closed: true }, style: { fill: `${colors[seriesIndex]}33`, stroke: 'transparent' } })); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { stroke: colors[seriesIndex], strokeWidth: 2, fill: 'none' } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 5 }, bounds: { x: point.x - 8, y: point.y - 8, width: 16, height: 16 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 8, dataRef); }); });
212
212
  addLegend(scene, spec, series.map((encoding, index) => ({ name: encoding.name || encoding.field, color: colors[index % colors.length] })));
213
213
  } else if (spec.type === 'bar' || spec.type === 'column') {
214
214
  const band = state.plot.width / Math.max(1, state.categories.length), bar = band * 0.58;
215
215
  const mode = typeof spec.stack === 'string' ? spec.stack : spec.stack?.mode, accumulators = data.rows.map(() => ({ positive: 0, negative: 0 }));
216
- series.forEach((encoding, seriesIndex) => { if (encoding.mark === 'line') { const yMap = encoding.axis === 'right' ? state.yRight : state.y, points = data.rows.map((row, index) => ({ x: state.x(row[state.xField]), y: yMap(row[encoding.field]), index, value: Number(row[encoding.field]) })).filter(point => Number.isFinite(point.y)); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points }, style: { fill: 'none', stroke: colors[seriesIndex], strokeWidth: 2 } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 7, dataRef); }); return; } data.rows.forEach((row, index) => { let value = Number(row[encoding.field]) || 0; const barSeries = series.filter(item => item.mark !== 'line'), barSeriesIndex = barSeries.indexOf(encoding), total = barSeries.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || 1; if (mode === 'percent') value /= total; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = start + value; if (mode) accumulators[index][accumulator] = end; const yMap = encoding.axis === 'right' ? state.yRight : state.y, base = yMap(start), valueY = yMap(end); const vertical = spec.type === 'column'; const horizontalStart = state.plot.x + ((start - state.min) / (state.max - state.min || 1)) * state.plot.width, horizontalEnd = state.plot.x + ((end - state.min) / (state.max - state.min || 1)) * state.plot.width, rowBand = state.plot.height / Math.max(1, data.rows.length), totalHeight = rowBand * .62, itemHeight = mode ? totalHeight : totalHeight / Math.max(1, barSeries.length); const geometry = vertical ? { x: state.x(row[state.xField]) - bar / 2 + (mode ? 0 : seriesIndex * bar / Math.max(1, series.length)), y: Math.min(base, valueY), width: mode ? bar : bar / Math.max(1, series.length), height: Math.abs(base - valueY) } : { x: Math.min(horizontalStart, horizontalEnd), y: state.plot.y + index * rowBand + (rowBand - totalHeight) / 2 + (mode ? 0 : barSeriesIndex * itemHeight), width: Math.abs(horizontalEnd - horizontalStart), height: itemHeight }; const dataRef = { seriesIndex, dataIndex: index, field: encoding.field, stackStart: start, stackEnd: end }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${index}`, value, vertical ? geometry.x + geometry.width / 2 : geometry.x + geometry.width + 12, vertical ? geometry.y - 5 : geometry.y + geometry.height / 2 + 4, dataRef); }); });
216
+ series.forEach((encoding, seriesIndex) => { if (encoding.mark === 'line') { const yMap = encoding.axis === 'right' ? state.yRight : state.y, points = data.rows.map((row, index) => ({ x: state.x(row[state.xField]), y: yMap(row[encoding.field]), index, value: Number(row[encoding.field]) })).filter(point => Number.isFinite(point.y)); if (points.length > 1) scene.add(new SceneNode({ id: `series-${seriesIndex}-line`, type: 'path', geometry: { points, closed: false }, style: { fill: 'none', stroke: colors[seriesIndex], strokeWidth: 2 } })); points.forEach(point => { const dataRef = { seriesIndex, dataIndex: point.index, field: encoding.field }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${point.index}`, type: 'circle', geometry: { cx: point.x, cy: point.y, r: 4 }, bounds: { x: point.x - 7, y: point.y - 7, width: 14, height: 14 }, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${point.index}`, point.value, point.x, point.y - 7, dataRef); }); return; } data.rows.forEach((row, index) => { let value = Number(row[encoding.field]) || 0; const barSeries = series.filter(item => item.mark !== 'line'), barSeriesIndex = barSeries.indexOf(encoding), total = barSeries.reduce((sum, item) => sum + Math.abs(Number(row[item.field]) || 0), 0) || 1; if (mode === 'percent') value /= total; const accumulator = value >= 0 ? 'positive' : 'negative', start = mode ? accumulators[index][accumulator] : 0, end = start + value; if (mode) accumulators[index][accumulator] = end; const yMap = encoding.axis === 'right' ? state.yRight : state.y, base = yMap(start), valueY = yMap(end); const vertical = spec.type === 'column'; const horizontalStart = state.plot.x + ((start - state.min) / (state.max - state.min || 1)) * state.plot.width, horizontalEnd = state.plot.x + ((end - state.min) / (state.max - state.min || 1)) * state.plot.width, rowBand = state.plot.height / Math.max(1, data.rows.length), totalHeight = rowBand * .62, itemHeight = mode ? totalHeight : totalHeight / Math.max(1, barSeries.length); const geometry = vertical ? { x: state.x(row[state.xField]) - bar / 2 + (mode ? 0 : seriesIndex * bar / Math.max(1, series.length)), y: Math.min(base, valueY), width: mode ? bar : bar / Math.max(1, series.length), height: Math.abs(base - valueY) } : { x: Math.min(horizontalStart, horizontalEnd), y: state.plot.y + index * rowBand + (rowBand - totalHeight) / 2 + (mode ? 0 : barSeriesIndex * itemHeight), width: Math.abs(horizontalEnd - horizontalStart), height: itemHeight }; const dataRef = { seriesIndex, dataIndex: index, field: encoding.field, stackStart: start, stackEnd: end }; scene.add(new SceneNode({ id: `series-${seriesIndex}-item-${index}`, type: 'rect', geometry, bounds: geometry, style: { fill: colors[seriesIndex] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-${seriesIndex}-item-${index}`, value, vertical ? geometry.x + geometry.width / 2 : geometry.x + geometry.width + 12, vertical ? geometry.y - 5 : geometry.y + geometry.height / 2 + 4, dataRef); }); });
217
217
  addLegend(scene, spec, series.map((encoding, index) => ({ name: encoding.name || encoding.field, color: colors[index % colors.length] })));
218
218
  } else if (spec.type === 'scatter') {
219
219
  data.rows.forEach((row, index) => { const xValue = Number(row[state.xField]), yValue = Number(row[state.yField]); if (!Number.isFinite(xValue) || !Number.isFinite(yValue)) return; const geometry = { cx: state.x(xValue), cy: state.y(yValue), r: 5 }, dataRef = { seriesIndex: 0, dataIndex: index, field: state.yField }; scene.add(new SceneNode({ id: `series-0-item-${index}`, type: 'circle', geometry, bounds: { x: geometry.cx - 8, y: geometry.cy - 8, width: 16, height: 16 }, style: { fill: colors[0] }, dataRef, interactive: true })); addMarkLabel(scene, spec, `series-0-item-${index}`, yValue, geometry.cx, geometry.cy - 8, dataRef); });
package/src/renderer.mjs CHANGED
@@ -2,6 +2,22 @@
2
2
  * Canvas and SVG renderer implementations for the shared Scene Graph.
3
3
  * Rendering is intentionally separate from chart layout and data semantics.
4
4
  */
5
+ const NO_FILL_TOKENS = new Set(['none', 'transparent', '']);
6
+ function color(style, key, fallback) { return style && style[key] != null ? style[key] : fallback; }
7
+ function hasFill(style) {
8
+ if (!style) return false;
9
+ const v = style.fill;
10
+ if (v == null) return false;
11
+ if (typeof v !== 'string') return true;
12
+ return !NO_FILL_TOKENS.has(v.trim().toLowerCase());
13
+ }
14
+ function hasStroke(style) {
15
+ if (!style) return false;
16
+ const v = style.stroke;
17
+ if (v == null) return false;
18
+ if (typeof v !== 'string') return true;
19
+ return !NO_FILL_TOKENS.has(v.trim().toLowerCase());
20
+ }
5
21
  export class BaseRenderer {
6
22
  constructor(options = {}) { this.options = options; this.container = null; }
7
23
  mount(container) { this.container = typeof container === 'string' ? document.querySelector(container) : container; if (!this.container) throw new Error('Chart container was not found.'); return this; }
@@ -11,13 +27,11 @@ export class BaseRenderer {
11
27
  destroy() { this.clear(); this.container = null; }
12
28
  }
13
29
 
14
- function color(style, key, fallback) { return style && style[key] != null ? style[key] : fallback; }
15
-
16
30
  export class CanvasRenderer extends BaseRenderer {
17
31
  mount(container) { super.mount(container); this.canvas = this.container.tagName === 'CANVAS' ? this.container : document.createElement('canvas'); if (this.canvas !== this.container) { this.container.innerHTML = ''; this.container.appendChild(this.canvas); } this.canvas.style.background = this.options.background || '#ffffff'; this.ctx = this.canvas.getContext('2d'); return this; }
18
32
  resize(width, height, pixelRatio = window.devicePixelRatio || 1) { this.width = width; this.height = height; this.pixelRatio = pixelRatio; this.canvas.width = Math.round(width * pixelRatio); this.canvas.height = Math.round(height * pixelRatio); this.canvas.style.width = `${width}px`; this.canvas.style.height = `${height}px`; this.ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); }
19
33
  clear() { if (this.ctx) this.ctx.clearRect(0, 0, this.width, this.height); }
20
- render(scene) { this.clear(); const ctx = this.ctx; const draw = node => { if (!node.visible) return; const g = node.geometry || {}; const s = node.style || {}; ctx.save(); ctx.globalAlpha = s.opacity == null ? 1 : s.opacity; ctx.fillStyle = color(s, 'fill', node.highlighted || node.selected ? '#1d4ed8' : '#2563eb'); ctx.strokeStyle = color(s, 'stroke', 'transparent'); ctx.lineWidth = s.strokeWidth || 1; if (node.type === 'rect') { ctx.fillRect(g.x, g.y, g.width, g.height); if (s.stroke) ctx.strokeRect(g.x, g.y, g.width, g.height); } else if (node.type === 'line') { ctx.beginPath(); ctx.moveTo(g.x1, g.y1); ctx.lineTo(g.x2, g.y2); ctx.stroke(); } else if (node.type === 'path') { ctx.beginPath(); g.points.forEach((point, index) => index ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y)); if (g.closed) ctx.closePath(); ctx.stroke(); if (s.fill) ctx.fill(); } else if (node.type === 'circle') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, 0, Math.PI * 2); ctx.fill(); if (s.stroke) ctx.stroke(); } else if (node.type === 'arc') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, g.start, g.end); if (g.innerR > 0) { ctx.arc(g.cx, g.cy, g.innerR, g.end, g.start, true); } else ctx.lineTo(g.cx, g.cy); ctx.closePath(); ctx.fill(); if (s.stroke) ctx.stroke(); } else if (node.type === 'text') { ctx.font = s.font || '12px system-ui'; const align = s.textAlign || (s.textAnchor === 'end' ? 'end' : s.textAnchor === 'middle' || s.textAnchor === 'center' ? 'center' : s.textAnchor === 'start' ? 'start' : 'left'); ctx.textAlign = align; ctx.textBaseline = s.textBaseline || s.baseline || 'alphabetic'; ctx.fillText(g.text, g.x, g.y); } node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(draw); ctx.restore(); }; draw(scene.root); }
34
+ render(scene) { this.clear(); const ctx = this.ctx; const draw = node => { if (!node.visible) return; const g = node.geometry || {}; const s = node.style || {}; ctx.save(); ctx.globalAlpha = s.opacity == null ? 1 : s.opacity; ctx.fillStyle = hasFill(s) ? color(s, 'fill', node.highlighted || node.selected ? '#1d4ed8' : '#2563eb') : 'rgba(0,0,0,0)'; ctx.strokeStyle = hasStroke(s) ? color(s, 'stroke', '#0f172a') : 'rgba(0,0,0,0)'; ctx.lineWidth = s.strokeWidth || 1; if (node.type === 'rect') { if (hasFill(s)) ctx.fillRect(g.x, g.y, g.width, g.height); if (hasStroke(s)) ctx.strokeRect(g.x, g.y, g.width, g.height); } else if (node.type === 'line') { ctx.beginPath(); ctx.moveTo(g.x1, g.y1); ctx.lineTo(g.x2, g.y2); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'path') { ctx.beginPath(); g.points.forEach((point, index) => index ? ctx.lineTo(point.x, point.y) : ctx.moveTo(point.x, point.y)); if (g.closed) ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'circle') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, 0, Math.PI * 2); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'arc') { ctx.beginPath(); ctx.arc(g.cx, g.cy, g.r, g.start, g.end); if (g.innerR > 0) { ctx.arc(g.cx, g.cy, g.innerR, g.end, g.start, true); } else ctx.lineTo(g.cx, g.cy); ctx.closePath(); if (hasFill(s)) ctx.fill(); if (hasStroke(s)) ctx.stroke(); } else if (node.type === 'text') { if (!hasFill(s)) ctx.fillStyle = color(s, 'fill', '#0f172a'); ctx.font = s.font || '12px system-ui'; const align = s.textAlign || (s.textAnchor === 'end' ? 'end' : s.textAnchor === 'middle' || s.textAnchor === 'center' ? 'center' : s.textAnchor === 'start' ? 'start' : 'left'); ctx.textAlign = align; ctx.textBaseline = s.textBaseline || s.baseline || 'alphabetic'; ctx.fillText(g.text, g.x, g.y); } node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(draw); ctx.restore(); }; draw(scene.root); }
21
35
  exportImage(type = 'image/png') { return this.canvas.toDataURL(type); }
22
36
  }
23
37
 
@@ -72,6 +86,6 @@ export class SVGRenderer extends BaseRenderer {
72
86
  this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
73
87
  this.width = w; this.height = h;
74
88
  }
75
- this.clear(); const renderNode = node => { if (!node.visible || node.type === 'root') { node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); return; } const g = node.geometry || {}, s = node.style || {}; const element = document.createElementNS('http://www.w3.org/2000/svg', node.type === 'path' || node.type === 'arc' ? 'path' : node.type === 'text' ? 'text' : node.type); element.setAttribute('id', node.id); element.setAttribute('data-node-id', node.id); if (node.dataRef) element.dataset.dataRef = JSON.stringify(node.dataRef); if (node.type === 'rect') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.setAttribute('width', g.width); element.setAttribute('height', g.height); } else if (node.type === 'line') { ['x1', 'y1', 'x2', 'y2'].forEach(key => element.setAttribute(key, g[key])); } else if (node.type === 'circle') { element.setAttribute('cx', g.cx); element.setAttribute('cy', g.cy); element.setAttribute('r', g.r); } else if (node.type === 'arc') { const large = g.end - g.start > Math.PI ? 1 : 0, outerStart = `${g.cx + g.r * Math.cos(g.start)} ${g.cy + g.r * Math.sin(g.start)}`, outerEnd = `${g.cx + g.r * Math.cos(g.end)} ${g.cy + g.r * Math.sin(g.end)}`; const p = g.innerR > 0 ? [`M ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, `L ${g.cx + g.innerR * Math.cos(g.end)} ${g.cy + g.innerR * Math.sin(g.end)}`, `A ${g.innerR} ${g.innerR} 0 ${large} 0 ${g.cx + g.innerR * Math.cos(g.start)} ${g.cy + g.innerR * Math.sin(g.start)}`, 'Z'] : [`M ${g.cx} ${g.cy}`, `L ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, 'Z']; element.setAttribute('d', p.join(' ')); } else if (node.type === 'path') element.setAttribute('d', `${g.points.map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`); else if (node.type === 'text') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.textContent = g.text; const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start'); if (anchor) element.setAttribute('text-anchor', anchor); const baseline = s.textBaseline || s.baseline || 'alphabetic'; if (baseline === 'top') { element.setAttribute('dominant-baseline', 'text-before-edge'); element.setAttribute('alignment-baseline', 'before-edge'); } else if (baseline === 'middle' || baseline === 'central') { element.setAttribute('dominant-baseline', 'middle'); element.setAttribute('alignment-baseline', 'middle'); } else if (baseline === 'bottom' || baseline === 'hanging') { element.setAttribute('dominant-baseline', 'text-after-edge'); element.setAttribute('alignment-baseline', 'after-edge'); } else { element.setAttribute('dominant-baseline', 'alphabetic'); element.setAttribute('alignment-baseline', 'alphabetic'); } if (s.font) { const raw = String(s.font).trim(); element.setAttribute('font', raw); const sizeMatch = raw.match(/\b(\d+(?:\.\d+)?)\s*(px|em|rem|pt|%)/i); const size = sizeMatch ? `${sizeMatch[1]}${sizeMatch[2].toLowerCase()}` : ''; if (size) element.style.fontSize = size; const weightMatch = raw.match(/^\s*(\d{3}|normal|bold|lighter|bolder)\b/i); if (weightMatch) element.style.fontWeight = weightMatch[1]; const family = raw.replace(/^\s*(?:(?:normal|italic|oblique)(?:\s+[^0-9\s]+)?\s+)?(?:\d{3}|normal|bold|lighter|bolder)\s+/i, '').replace(/\b\d+(?:\.\d+)?\s*(?:px|em|rem|pt|%)(?:\s*\/\s*\S+)?\s*/i, '').trim(); if (family) element.style.fontFamily = family; } } if (node.type === 'path' && !s.fill) element.setAttribute('fill', 'none'); else if (s.fill) element.setAttribute('fill', s.fill); if (s.stroke) element.setAttribute('stroke', s.stroke); if (s.strokeWidth) element.setAttribute('stroke-width', s.strokeWidth); if (s.opacity != null) element.setAttribute('opacity', node.highlighted || node.selected ? 1 : s.opacity); if (node.highlighted || node.selected) element.setAttribute('filter', 'brightness(1.2)'); if (node.interactive) element.style.cursor = 'pointer'; if (s.pointerEvents === 'none') element.style.pointerEvents = 'none'; if (s.ariaHidden === 'true') element.setAttribute('aria-hidden', 'true'); if (s.role === 'presentation') element.setAttribute('role', 'presentation'); if (node.decorative) { element.setAttribute('aria-hidden', 'true'); element.setAttribute('role', 'presentation'); element.style.pointerEvents = 'none'; element.style.userSelect = 'none'; } this.svg.appendChild(element); node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }; scene.root.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }
89
+ this.clear(); const renderNode = node => { if (!node.visible || node.type === 'root') { node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); return; } const g = node.geometry || {}, s = node.style || {}; const element = document.createElementNS('http://www.w3.org/2000/svg', node.type === 'path' || node.type === 'arc' ? 'path' : node.type === 'text' ? 'text' : node.type); element.setAttribute('id', node.id); element.setAttribute('data-node-id', node.id); if (node.dataRef) element.dataset.dataRef = JSON.stringify(node.dataRef); if (node.type === 'rect') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.setAttribute('width', g.width); element.setAttribute('height', g.height); } else if (node.type === 'line') { ['x1', 'y1', 'x2', 'y2'].forEach(key => element.setAttribute(key, g[key])); } else if (node.type === 'circle') { element.setAttribute('cx', g.cx); element.setAttribute('cy', g.cy); element.setAttribute('r', g.r); } else if (node.type === 'arc') { const large = g.end - g.start > Math.PI ? 1 : 0, outerStart = `${g.cx + g.r * Math.cos(g.start)} ${g.cy + g.r * Math.sin(g.start)}`, outerEnd = `${g.cx + g.r * Math.cos(g.end)} ${g.cy + g.r * Math.sin(g.end)}`; const p = g.innerR > 0 ? [`M ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, `L ${g.cx + g.innerR * Math.cos(g.end)} ${g.cy + g.innerR * Math.sin(g.end)}`, `A ${g.innerR} ${g.innerR} 0 ${large} 0 ${g.cx + g.innerR * Math.cos(g.start)} ${g.cy + g.innerR * Math.sin(g.start)}`, 'Z'] : [`M ${g.cx} ${g.cy}`, `L ${outerStart}`, `A ${g.r} ${g.r} 0 ${large} 1 ${outerEnd}`, 'Z']; element.setAttribute('d', p.join(' ')); } else if (node.type === 'path') element.setAttribute('d', `${g.points.map((p, i) => `${i ? 'L' : 'M'} ${p.x} ${p.y}`).join(' ')}${g.closed ? ' Z' : ''}`); else if (node.type === 'text') { element.setAttribute('x', g.x); element.setAttribute('y', g.y); element.textContent = g.text; const anchor = s.textAnchor || (s.textAlign === 'end' ? 'end' : s.textAlign === 'center' ? 'middle' : s.textAlign === 'right' ? 'end' : s.textAlign === 'left' ? 'start' : 'start'); if (anchor) element.setAttribute('text-anchor', anchor); const baseline = s.textBaseline || s.baseline || 'alphabetic'; if (baseline === 'top') { element.setAttribute('dominant-baseline', 'text-before-edge'); element.setAttribute('alignment-baseline', 'before-edge'); } else if (baseline === 'middle' || baseline === 'central') { element.setAttribute('dominant-baseline', 'middle'); element.setAttribute('alignment-baseline', 'middle'); } else if (baseline === 'bottom' || baseline === 'hanging') { element.setAttribute('dominant-baseline', 'text-after-edge'); element.setAttribute('alignment-baseline', 'after-edge'); } else { element.setAttribute('dominant-baseline', 'alphabetic'); element.setAttribute('alignment-baseline', 'alphabetic'); } if (s.font) { const raw = String(s.font).trim(); element.setAttribute('font', raw); const sizeMatch = raw.match(/\b(\d+(?:\.\d+)?)\s*(px|em|rem|pt|%)/i); const size = sizeMatch ? `${sizeMatch[1]}${sizeMatch[2].toLowerCase()}` : ''; if (size) element.style.fontSize = size; const weightMatch = raw.match(/^\s*(\d{3}|normal|bold|lighter|bolder)\b/i); if (weightMatch) element.style.fontWeight = weightMatch[1]; const family = raw.replace(/^\s*(?:(?:normal|italic|oblique)(?:\s+[^0-9\s]+)?\s+)?(?:\d{3}|normal|bold|lighter|bolder)\s+/i, '').replace(/\b\d+(?:\.\d+)?\s*(?:px|em|rem|pt|%)(?:\s*\/\s*\S+)?\s*/i, '').trim(); if (family) element.style.fontFamily = family; } } if (node.type === 'path' || node.type === 'circle' || node.type === 'rect' || node.type === 'arc') { if (hasFill(s)) element.setAttribute('fill', s.fill); else element.setAttribute('fill', node.type === 'path' ? 'none' : 'black'); } else if (node.type === 'text') { element.setAttribute('fill', hasFill(s) ? s.fill : color(s, 'fill', '#0f172a')); } if (hasStroke(s)) element.setAttribute('stroke', s.stroke); else if (s.stroke != null && typeof s.stroke === 'string' && NO_FILL_TOKENS.has(s.stroke.trim().toLowerCase())) element.setAttribute('stroke', 'none'); if (s.strokeWidth != null) element.setAttribute('stroke-width', s.strokeWidth); if (s.opacity != null) element.setAttribute('opacity', node.highlighted || node.selected ? 1 : s.opacity); if (node.highlighted || node.selected) element.setAttribute('filter', 'brightness(1.2)'); if (node.interactive) element.style.cursor = 'pointer'; if (s.pointerEvents === 'none') element.style.pointerEvents = 'none'; if (s.ariaHidden === 'true') element.setAttribute('aria-hidden', 'true'); if (s.role === 'presentation') element.setAttribute('role', 'presentation'); if (node.decorative) { element.setAttribute('aria-hidden', 'true'); element.setAttribute('role', 'presentation'); element.style.pointerEvents = 'none'; element.style.userSelect = 'none'; } this.svg.appendChild(element); node.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }; scene.root.children.slice().sort((a, b) => a.zIndex - b.zIndex).forEach(renderNode); }
76
90
  exportString() { if (!this.svg) return ''; return new XMLSerializer().serializeToString(this.svg); }
77
91
  }