From 9e327358a89e329786900a50a933ac0101ba43e2 Mon Sep 17 00:00:00 2001 From: Nabil-Fareed Alikhan Date: Fri, 4 Sep 2026 08:33:31 +0100 Subject: [PATCH 1/2] Centralize plot geometry across renderers --- __tests__/plot-scene.test.ts | 194 ++++++++ __tests__/renderer-export.test.ts | 6 + lib/canvas-renderer.ts | 394 ++++------------ lib/plotScene.ts | 409 +++++++++++++++++ lib/renderer.ts | 736 +++++++----------------------- lib/rendering/canvasLegends.ts | 171 +++---- lib/rendering/legendLayout.ts | 151 ++++++ lib/rendering/svgLegends.ts | 238 ++++------ src/pages/Home.tsx | 10 +- 9 files changed, 1180 insertions(+), 1129 deletions(-) create mode 100644 __tests__/plot-scene.test.ts create mode 100644 lib/plotScene.ts create mode 100644 lib/rendering/legendLayout.ts diff --git a/__tests__/plot-scene.test.ts b/__tests__/plot-scene.test.ts new file mode 100644 index 0000000..7a34275 --- /dev/null +++ b/__tests__/plot-scene.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; +import { buildPlotScene, selectDisplayedPlotData } from '@/lib/plotScene'; +import type { CircularPlotData } from '@/lib/types'; +import type { RenderConfig } from '@/lib/rendering/types'; + +const config: RenderConfig = { + width: 1000, + height: 800, + innerRadius: 200, + ringWidth: 20, + gcRingWidth: 40, + ringSpacing: 4, + minIdentity: 70, + maxIdentity: 100, + legendFontSize: 16, + scaleFontSize: 12, + titleFontSize: 24, + labelFontSize: 14, + title: '', +}; + +function plot(): CircularPlotData { + return { + reference: { + name: 'reference', + length: 10_000, + gcContent: [0.4, 0.6], + gcSkew: [-0.2, 0.2], + features: [{ type: 'CDS', start: 100, end: 200, strand: '+', name: 'feature' }], + annotations: [{ id: 'reference-note', start: 300, end: 400, label: 'note', shape: 'block' }], + contigs: [ + { name: 'one', start: 0, end: 5_000, index: 0 }, + { name: 'two', start: 5_000, end: 10_000, index: 1 }, + ], + }, + rings: [ + { + queryId: 'alignment', + queryName: 'Alignment', + color: '#3366cc', + visible: true, + customWidth: 30, + hits: [ + { + queryName: 'Alignment', + refStart: 100, + refEnd: 1_000, + queryStart: 1, + queryEnd: 901, + percentIdentity: 95, + alignmentLength: 900, + strand: '+', + }, + { + queryName: 'Alignment', + refStart: 200, + refEnd: 300, + queryStart: 1, + queryEnd: 101, + percentIdentity: 99, + alignmentLength: 100, + strand: '+', + }, + ], + annotations: [{ id: 'ring-note', start: 500, end: 600, label: 'ring note', shape: 'block' }], + statistics: { meanIdentity: 0, genomeCoverage: 0, totalAlignedBases: 0 }, + }, + { + queryId: 'hidden', + queryName: 'Hidden', + color: '#ff0000', + visible: false, + hits: [], + statistics: { meanIdentity: 0, genomeCoverage: 0, totalAlignedBases: 0 }, + }, + { + queryId: 'graph', + queryName: 'Graph', + color: '#00aa00', + visible: true, + hits: [], + graphPoints: [{ start: 0, end: 1_000, value: 5 }], + graphMaxCap: 4, + statistics: { meanIdentity: 0, genomeCoverage: 0, totalAlignedBases: 0 }, + }, + ], + config: { minIdentity: 70, minAlignmentLength: 100 }, + }; +} + +describe('buildPlotScene', () => { + it('creates one ordered track plan for both rendering backends', () => { + const scene = buildPlotScene(plot(), config); + + expect(scene).toMatchObject({ + width: 1000, + height: 800, + centerX: 500, + centerY: 400, + referenceLength: 10_000, + }); + expect(scene.referenceAnnotations.map(annotation => annotation.label)).toEqual(['feature', 'note']); + expect(scene.gcContent?.layout).toMatchObject({ type: 'gc-content', radius: 204, width: 40 }); + expect(scene.gcSkew?.layout).toMatchObject({ type: 'gc-skew', radius: 248, width: 40 }); + expect(scene.rings.map(track => ({ + id: track.ring.queryId, + type: track.layout.type, + radius: track.layout.radius, + width: track.layout.width, + }))).toEqual([ + { id: 'alignment', type: 'alignment', radius: 292, width: 30 }, + { id: 'graph', type: 'graph', radius: 326, width: 20 }, + ]); + expect(scene.rings[0].annotations).toHaveLength(1); + expect(scene.rings[0].alignmentArcs).toHaveLength(1); + expect(scene.rings[0].alignmentArcs[0]).toMatchObject({ + innerRadius: 292, + outerRadius: 322, + fill: 'rgb(85, 128, 213)', + identity: 95, + }); + expect(scene.rings[1].graphArcs[0]).toMatchObject({ + innerRadius: 326, + outerRadius: 346, + fill: 'rgb(30, 100, 220)', + value: 5, + }); + expect(scene.contigLayout).toMatchObject({ type: 'contig', radius: 350, width: 6 }); + expect(scene.contigArcs.map(arc => arc.fill)).toEqual(['#ef4444', '#3b82f6']); + expect(scene.scaleMarkers).toHaveLength(12); + expect(scene.gcContent?.arcs).toHaveLength(2); + expect(scene.gcSkew?.arcs).toHaveLength(2); + expect(scene.gcLegend?.sections.map(section => section.title)).toEqual([ + 'GC Content', + 'GC Skew', + ]); + expect(scene.ringLegend?.sections.map(section => ({ + kind: section.kind, + title: section.title, + }))).toEqual([ + { kind: 'gradient', title: 'Alignment' }, + { kind: 'swatch', title: 'Graph' }, + ]); + }); + + it('removes absent GC tracks without leaving radial gaps', () => { + const data = plot(); + data.reference.gcContent = undefined; + const scene = buildPlotScene(data, config); + + expect(scene.gcContent).toBeNull(); + expect(scene.gcSkew?.layout).toMatchObject({ radius: 200 }); + expect(scene.rings[0].layout).toMatchObject({ radius: 244 }); + expect(scene.gcLegend?.sections.map(section => section.title)).toEqual(['GC Skew']); + }); + + it('retains the exact interactive view state', () => { + const viewState = { + zoom: 1.5, + panX: 20, + panY: -10, + gcLegendPos: { x: 50, y: 60 }, + ringLegendPos: { x: 700, y: 80 }, + }; + + const scene = buildPlotScene(plot(), config, viewState); + + expect(scene.viewState).toBe(viewState); + expect(scene.gcLegend?.bounds).toMatchObject({ x: 45, y: 60 }); + expect(scene.ringLegend?.bounds).toMatchObject({ x: 695, y: 80 }); + }); + + it('omits all legend geometry when legends are disabled', () => { + const scene = buildPlotScene(plot(), { ...config, showLegend: false }); + + expect(scene.gcLegend).toBeNull(); + expect(scene.ringLegend).toBeNull(); + }); +}); + +describe('selectDisplayedPlotData', () => { + it('hides GC tracks without mutating or discarding the source analysis', () => { + const source = plot(); + const displayed = selectDisplayedPlotData(source, { + showGCContent: false, + showGCSkew: true, + }); + + expect(displayed.reference.gcContent).toBeUndefined(); + expect(displayed.reference.gcSkew).toEqual([-0.2, 0.2]); + expect(source.reference.gcContent).toEqual([0.4, 0.6]); + expect(source.reference.gcSkew).toEqual([-0.2, 0.2]); + }); +}); diff --git a/__tests__/renderer-export.test.ts b/__tests__/renderer-export.test.ts index 0cfda2f..5155ce2 100644 --- a/__tests__/renderer-export.test.ts +++ b/__tests__/renderer-export.test.ts @@ -89,6 +89,12 @@ describe('editable SVG export', () => { expect(svg.getAttribute('xmlns:inkscape')).toBe('http://www.inkscape.org/namespaces/inkscape'); expect(svg.querySelector('#defs')?.querySelectorAll('linearGradient').length).toBeGreaterThan(0); + expect(svg.querySelector('#defs #gc-content-grad')).not.toBeNull(); + expect(svg.querySelector('#defs #gc-skew-grad')).not.toBeNull(); + expect(svg.querySelector('#defs #grad-ring-1')).not.toBeNull(); + expect(svg.querySelector('#gc-legend')?.textContent).toContain('GC Content'); + expect(svg.querySelector('#gc-legend')?.textContent).toContain('GC Skew'); + expect(svg.querySelector('#ring-legend')?.textContent).toContain('Query One'); expect(svg.querySelector('#ring-ring-1')?.getAttribute('inkscape:label')).toBe('Ring: Query One'); expect(svg.querySelector('#annotations-ring-1')?.getAttribute('inkscape:label')).toBe('Annotations: Query One'); expect(svg.querySelector('#reference-annotations')?.textContent).toContain('reference-gene'); diff --git a/lib/canvas-renderer.ts b/lib/canvas-renderer.ts index 7fcc1ea..4d75842 100644 --- a/lib/canvas-renderer.ts +++ b/lib/canvas-renderer.ts @@ -1,12 +1,18 @@ // Canvas 2D renderer for circular genome plot (display only; SVG renderer kept for export) -import type { CircularPlotData, RingData, Annotation, ContigBoundary } from './types'; -import { collectReferenceAnnotations } from './referenceAnnotations'; +import type { CircularPlotData, Annotation } from './types'; import type { LegendBounds, PlotTooltip, RenderConfig } from './rendering/types'; -import { positionToAngle, hexToRGB, getColorIntensity, calculateRingLayout } from './geometry'; +import { + buildPlotScene, + type AlignmentArcScene, + type ContigArcScene, + type GraphArcScene, + type MetricRingScene, + type PlotScene, +} from './plotScene'; import { drawCanvasAnnotations } from './rendering/canvasAnnotations'; import { drawAnnularArc, drawCircle } from './rendering/canvasPrimitives'; -import { drawCanvasGCLegend, drawCanvasRingLegend } from './rendering/canvasLegends'; +import { drawCanvasLegend } from './rendering/canvasLegends'; interface HitRegion { innerR: number; @@ -186,11 +192,18 @@ export class CanvasPlotRenderer { const ctx = canvas.getContext('2d'); if (!ctx) return; - const w = this.config.width; - const h = this.config.height; - const cx = w / 2; - const cy = h / 2; - const refLength = data.reference.length; + const scene = buildPlotScene(data, this.config, { + zoom, + panX, + panY, + gcLegendPos: this.gcLegendPos, + ringLegendPos: this.ringLegendPos, + }); + const w = scene.width; + const h = scene.height; + const cx = scene.centerX; + const cy = scene.centerY; + const refLength = scene.referenceLength; // Clear and fill white background ctx.setTransform(dpr, 0, 0, dpr, 0, 0); @@ -207,71 +220,46 @@ export class CanvasPlotRenderer { drawCircle(ctx, cx, cy, this.config.innerRadius, '#333', 2); // Reference features from GenBank/GBFF or a companion annotation file. - const referenceAnnotations = collectReferenceAnnotations( - data.reference.features, - data.reference.annotations, - ); - if (referenceAnnotations.length > 0) { + if (scene.referenceAnnotations.length > 0) { const featureInner = Math.max(10, this.config.innerRadius - 30); const featureOuter = this.config.innerRadius; - this.drawAnnotations(ctx, cx, cy, refLength, referenceAnnotations, featureInner, featureOuter, true, true); + this.drawAnnotations(ctx, cx, cy, refLength, scene.referenceAnnotations, featureInner, featureOuter, true, true); } - // --- Ring layout --- - const layouts = calculateRingLayout( - { - innerRadius: this.config.innerRadius, - ringWidth: this.config.ringWidth, - gcRingWidth: this.config.gcRingWidth, - ringSpacing: this.config.ringSpacing, - }, - { - hasGCContent: !!data.reference.gcContent, - hasGCSkew: !!data.reference.gcSkew, - rings: data.rings || [], - hasContigs: !!(data.reference.contigs && data.reference.contigs.length > 1), - } - ); - // GC Content ring - if (data.reference.gcContent) { - const layout = layouts.find(l => l.type === 'gc-content')!; - this.drawGCRing(ctx, cx, cy, refLength, data.reference.gcContent, layout.radius); + if (scene.gcContent) { + this.drawMetricRing(ctx, cx, cy, scene.gcContent); } // GC Skew ring - if (data.reference.gcSkew) { - const layout = layouts.find(l => l.type === 'gc-skew')!; - this.drawGCSkewRing(ctx, cx, cy, refLength, data.reference.gcSkew, layout.radius); + if (scene.gcSkew) { + this.drawMetricRing(ctx, cx, cy, scene.gcSkew); } // Query rings (alignment + graph) - const visibleRings = (data.rings || []).filter(r => r.visible); - for (const ring of visibleRings) { - const layout = layouts.find(l => l.queryId === ring.queryId); - if (!layout) continue; + for (const track of scene.rings) { + const { ring, layout, annotations } = track; const ringWidth = layout.width; const radius = layout.radius; if (ring.graphPoints && ring.graphPoints.length > 0) { - this.drawGraphRing(ctx, cx, cy, refLength, ring, radius, ringWidth); + this.drawGraphRing(ctx, cx, cy, layout, track.graphArcs); } else { - this.drawQueryRing(ctx, cx, cy, refLength, ring, radius, ringWidth); + this.drawQueryRing(ctx, cx, cy, track.alignmentArcs); } - if (ring.annotations && ring.annotations.length > 0) { - this.drawAnnotations(ctx, cx, cy, refLength, ring.annotations, radius, radius + ringWidth, ring.showLabels !== false); + if (annotations.length > 0) { + this.drawAnnotations(ctx, cx, cy, refLength, annotations, radius, radius + ringWidth, ring.showLabels !== false); } } // Contig boundaries - if (data.reference.contigs && data.reference.contigs.length > 1) { - const layout = layouts.find(l => l.type === 'contig')!; - this.drawContigBoundaries(ctx, cx, cy, refLength, data.reference.contigs, layout.radius, layout.width); + if (scene.contigs.length > 0 && scene.contigLayout) { + this.drawContigBoundaries(ctx, cx, cy, scene.contigArcs); } // Scale markers - this.drawScaleMarkers(ctx, cx, cy, refLength); + this.drawScaleMarkers(ctx, scene.scaleMarkers); // Title this.drawTitle(ctx, cx, cy, refLength); @@ -280,14 +268,8 @@ export class CanvasPlotRenderer { ctx.restore(); // Legends remain fixed while the map is panned or zoomed. - if (this.config.showLegend !== false) { - if (data.reference.gcContent || data.reference.gcSkew) { - this.drawGCLegend(ctx, !!data.reference.gcContent, !!data.reference.gcSkew); - } - if (visibleRings.length > 0) { - this.drawRingLegend(ctx, visibleRings); - } - } + this.gcLegendBounds = scene.gcLegend ? drawCanvasLegend(ctx, scene.gcLegend) : null; + this.ringLegendBounds = scene.ringLegend ? drawCanvasLegend(ctx, scene.ringLegend) : null; } @@ -297,111 +279,28 @@ export class CanvasPlotRenderer { // ------- GC Content Ring ------- - private drawGCRing( + private drawMetricRing( ctx: CanvasRenderingContext2D, cx: number, cy: number, - refLength: number, - gcContent: number[], - ringRadius: number + scene: MetricRingScene, ): void { - const ringWidth = this.config.gcRingWidth; - const baseRadius = ringRadius + ringWidth / 2; - const maxBarHeight = ringWidth / 2; - const windowSize = refLength / gcContent.length; - - // 95th percentile scaling - const deviations = gcContent.map(gc => Math.abs(gc - 0.5)).sort((a, b) => a - b); - const p95 = deviations[Math.floor(deviations.length * 0.95)] || 0.1; - const scaleFactor = p95 > 0 ? 0.5 / p95 : 1; - - // Ring boundaries - drawCircle(ctx, cx, cy, ringRadius, '#ccc', 1); - drawCircle(ctx, cx, cy, ringRadius + ringWidth, '#ccc', 1); - // Baseline (dashed) - drawCircle(ctx, cx, cy, baseRadius, '#999', 1, [3, 3]); - - for (let i = 0; i < gcContent.length; i++) { - const gc = gcContent[i]; - const start = i * windowSize; - const end = (i + 1) * windowSize; - const startAngle = positionToAngle(start, refLength); - const endAngle = positionToAngle(end, refLength); - - const deviation = gc - 0.5; - const barHeight = Math.min(maxBarHeight, Math.abs(deviation) * scaleFactor * maxBarHeight); - - let innerR: number, outerR: number; - if (deviation >= 0) { - innerR = baseRadius; - outerR = baseRadius + barHeight; - } else { - innerR = baseRadius - barHeight; - outerR = baseRadius; - } - - const r = Math.floor((1 - gc) * 200 + 55); - const g = Math.floor(gc * 200 + 55); - const b = 50; - - drawAnnularArc(ctx, cx, cy, innerR, outerR, startAngle, endAngle, `rgb(${r}, ${g}, ${b})`, 0.8); - this.addHitRegion(innerR, outerR, startAngle, endAngle, { - type: 'gc-content', - gc: (gc * 100).toFixed(1), - position: Math.floor(start), - windowSize: Math.floor(windowSize), - }); - } - } - - // ------- GC Skew Ring ------- - - private drawGCSkewRing( - ctx: CanvasRenderingContext2D, - cx: number, cy: number, - refLength: number, - gcSkew: number[], - ringRadius: number - ): void { - const ringWidth = this.config.gcRingWidth; - const baseRadius = ringRadius + ringWidth / 2; - const maxBarHeight = ringWidth / 2; - const windowSize = refLength / gcSkew.length; - - const skewAbs = gcSkew.map(Math.abs).sort((a, b) => a - b); - const p95Skew = skewAbs[Math.floor(skewAbs.length * 0.95)] || 0.1; - const scaleFactor = p95Skew > 0 ? 1 / p95Skew : 1; - - drawCircle(ctx, cx, cy, ringRadius, '#ccc', 1); - drawCircle(ctx, cx, cy, ringRadius + ringWidth, '#ccc', 1); - drawCircle(ctx, cx, cy, baseRadius, '#999', 1, [3, 3]); - - for (let i = 0; i < gcSkew.length; i++) { - const skew = gcSkew[i]; - const start = i * windowSize; - const end = (i + 1) * windowSize; - const startAngle = positionToAngle(start, refLength); - const endAngle = positionToAngle(end, refLength); - - const barHeight = Math.min(maxBarHeight, Math.abs(skew) * scaleFactor * maxBarHeight); - let innerR: number, outerR: number, color: string; - - if (skew >= 0) { - innerR = baseRadius; - outerR = baseRadius + barHeight; - color = '#22c55e'; - } else { - innerR = baseRadius - barHeight; - outerR = baseRadius; - color = '#a855f7'; - } - - drawAnnularArc(ctx, cx, cy, innerR, outerR, startAngle, endAngle, color, 0.8); - this.addHitRegion(innerR, outerR, startAngle, endAngle, { - type: 'gc-skew', - skew: skew.toFixed(3), - position: Math.floor(start), - windowSize: Math.floor(windowSize), - }); + drawCircle(ctx, cx, cy, scene.layout.radius, '#ccc', 1); + drawCircle(ctx, cx, cy, scene.layout.radius + scene.layout.width, '#ccc', 1); + drawCircle(ctx, cx, cy, scene.baseRadius, '#999', 1, [3, 3]); + + for (const arc of scene.arcs) { + drawAnnularArc( + ctx, + cx, + cy, + arc.innerRadius, + arc.outerRadius, + arc.startAngle, + arc.endAngle, + arc.fill, + arc.opacity, + ); + this.addHitRegion(arc.innerRadius, arc.outerRadius, arc.startAngle, arc.endAngle, arc.tooltip); } } @@ -410,44 +309,11 @@ export class CanvasPlotRenderer { private drawQueryRing( ctx: CanvasRenderingContext2D, cx: number, cy: number, - refLength: number, - ring: RingData, - radius: number, - ringWidth: number + arcs: readonly AlignmentArcScene[], ): void { - if (!ring.hits || ring.hits.length === 0) return; - - // Sort hits largest first (smallest drawn last, on top) - const sortedHits = [...ring.hits].sort((a, b) => (b.refEnd - b.refStart) - (a.refEnd - a.refStart)); - - const drawnRegions: Array<{ start: number; end: number }> = []; - - for (const hit of sortedHits) { - const isOccluded = drawnRegions.some(region => hit.refStart >= region.start && hit.refEnd <= region.end); - if (isOccluded) continue; - - const startAngle = positionToAngle(hit.refStart, refLength); - const endAngle = positionToAngle(hit.refEnd, refLength); - - const color = getColorIntensity( - ring.color, - hit.percentIdentity, - ring.lowerThreshold ?? this.config.minIdentity, - ring.upperThreshold ?? 100 - ); - - drawAnnularArc(ctx, cx, cy, radius, radius + ringWidth, startAngle, endAngle, color); - - this.addHitRegion(radius, radius + ringWidth, startAngle, endAngle, { - type: 'alignment', - queryName: ring.queryName, - start: hit.refStart, - end: hit.refEnd, - identity: hit.percentIdentity, - coverage: 1.0, - }); - - drawnRegions.push({ start: hit.refStart, end: hit.refEnd }); + for (const arc of arcs) { + drawAnnularArc(ctx, cx, cy, arc.innerRadius, arc.outerRadius, arc.startAngle, arc.endAngle, arc.fill); + this.addHitRegion(arc.innerRadius, arc.outerRadius, arc.startAngle, arc.endAngle, arc.tooltip); } } @@ -456,38 +322,16 @@ export class CanvasPlotRenderer { private drawGraphRing( ctx: CanvasRenderingContext2D, cx: number, cy: number, - refLength: number, - ring: RingData, - radius: number, - ringWidth: number + layout: PlotScene['rings'][number]['layout'], + arcs: readonly GraphArcScene[], ): void { - const points = ring.graphPoints!; - const capValue = ring.graphMaxCap || ring.graphMaxValue || 1; - const { r: cr, g: cg, b: cb } = hexToRGB(ring.color); - // Ring boundaries - drawCircle(ctx, cx, cy, radius, '#ccc', 0.5); - drawCircle(ctx, cx, cy, radius + ringWidth, '#ccc', 0.5); - - for (const point of points) { - if (point.value <= 0) continue; - - const startAngle = positionToAngle(point.start, refLength); - const endAngle = positionToAngle(point.end, refLength); - const isOverCap = ring.graphMaxCap != null && point.value > capValue; - const fraction = Math.min(1, point.value / capValue); - const barHeight = fraction * ringWidth; - - const fill = isOverCap ? 'rgb(30, 100, 220)' : `rgb(${cr}, ${cg}, ${cb})`; - drawAnnularArc(ctx, cx, cy, radius, radius + barHeight, startAngle, endAngle, fill, 0.8); - - this.addHitRegion(radius, radius + barHeight, startAngle, endAngle, { - type: 'graph', - queryName: ring.queryName, - start: point.start, - end: point.end, - value: point.value.toFixed(2), - }); + drawCircle(ctx, cx, cy, layout.radius, '#ccc', 0.5); + drawCircle(ctx, cx, cy, layout.radius + layout.width, '#ccc', 0.5); + + for (const arc of arcs) { + drawAnnularArc(ctx, cx, cy, arc.innerRadius, arc.outerRadius, arc.startAngle, arc.endAngle, arc.fill, arc.opacity); + this.addHitRegion(arc.innerRadius, arc.outerRadius, arc.startAngle, arc.endAngle, arc.tooltip); } } @@ -496,45 +340,19 @@ export class CanvasPlotRenderer { private drawContigBoundaries( ctx: CanvasRenderingContext2D, cx: number, cy: number, - refLength: number, - contigs: ContigBoundary[], - radius: number, - ringWidth: number + arcs: readonly ContigArcScene[], ): void { - const colors = ['#ef4444', '#3b82f6']; - - for (const contig of contigs) { - const startAngle = positionToAngle(contig.start, refLength); - const endAngle = positionToAngle(contig.end, refLength); - const color = colors[contig.index % 2]; - - drawAnnularArc(ctx, cx, cy, radius, radius + ringWidth, startAngle, endAngle, color, 0.6); - - this.addHitRegion(radius, radius + ringWidth, startAngle, endAngle, { - type: 'contig', - name: contig.name, - start: contig.start, - end: contig.end, - length: contig.end - contig.start, - }); - - // Contig label - const midAngle = (startAngle + endAngle) / 2; - const labelRadius = radius + ringWidth + 8; - const arcSpan = contig.end - contig.start; - - if (arcSpan / refLength > 0.02) { - const lx = cx + labelRadius * Math.cos(midAngle); - const ly = cy + labelRadius * Math.sin(midAngle); - const fontSize = Math.max(7, this.config.scaleFontSize - 2); - const displayName = contig.name.length > 15 ? contig.name.substring(0, 12) + '...' : contig.name; + for (const arc of arcs) { + drawAnnularArc(ctx, cx, cy, arc.innerRadius, arc.outerRadius, arc.startAngle, arc.endAngle, arc.fill, arc.opacity); + this.addHitRegion(arc.innerRadius, arc.outerRadius, arc.startAngle, arc.endAngle, arc.tooltip); + if (arc.label) { ctx.save(); - ctx.font = `bold ${fontSize}px sans-serif`; - ctx.fillStyle = color; + ctx.font = `bold ${arc.label.fontSize}px sans-serif`; + ctx.fillStyle = arc.fill; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(displayName, lx, ly); + ctx.fillText(arc.label.text, arc.label.x, arc.label.y); ctx.restore(); } } @@ -547,7 +365,7 @@ export class CanvasPlotRenderer { centerX: number, centerY: number, referenceLength: number, - annotations: Annotation[], + annotations: readonly Annotation[], innerRadius: number, outerRadius: number, showLabels: boolean, @@ -574,44 +392,24 @@ export class CanvasPlotRenderer { private drawScaleMarkers( ctx: CanvasRenderingContext2D, - cx: number, cy: number, - refLength: number + markers: PlotScene['scaleMarkers'], ): void { - const numMarkers = 12; - const markerRadius = this.config.innerRadius; - - for (let i = 0; i < numMarkers; i++) { - const angle = (i / numMarkers) * 2 * Math.PI - Math.PI / 2; - const position = Math.floor((i / numMarkers) * refLength); - - const is3or9 = (i === 3 || i === 9); - const tickLength = is3or9 ? 8 : 18; - - const x1 = cx + (markerRadius - tickLength) * Math.cos(angle); - const y1 = cy + (markerRadius - tickLength) * Math.sin(angle); - const x2 = cx + (markerRadius + 3) * Math.cos(angle); - const y2 = cy + (markerRadius + 3) * Math.sin(angle); - + for (const marker of markers) { ctx.save(); ctx.strokeStyle = '#666'; ctx.lineWidth = 2; ctx.beginPath(); - ctx.moveTo(x1, y1); - ctx.lineTo(x2, y2); + ctx.moveTo(marker.line.x1, marker.line.y1); + ctx.lineTo(marker.line.x2, marker.line.y2); ctx.stroke(); ctx.restore(); - // Label - const labelRadius = markerRadius - 30; - const tx = cx + labelRadius * Math.cos(angle); - const ty = cy + labelRadius * Math.sin(angle); - ctx.save(); ctx.font = `${this.config.scaleFontSize}px sans-serif`; ctx.fillStyle = '#333'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText(`${(position / 1000).toFixed(0)}kb`, tx, ty); + ctx.fillText(marker.label.text, marker.label.x, marker.label.y); ctx.restore(); } } @@ -642,28 +440,4 @@ export class CanvasPlotRenderer { ctx.restore(); } - // ------- GC Legend (top-left) ------- - - private drawGCLegend( - context: CanvasRenderingContext2D, - hasGCContent: boolean, - hasGCSkew: boolean, - ): void { - this.gcLegendBounds = drawCanvasGCLegend( - context, - this.config, - this.gcLegendPos, - hasGCContent, - hasGCSkew, - ); - } - - private drawRingLegend(context: CanvasRenderingContext2D, rings: RingData[]): void { - this.ringLegendBounds = drawCanvasRingLegend( - context, - this.config, - this.ringLegendPos, - rings, - ); - } } diff --git a/lib/plotScene.ts b/lib/plotScene.ts new file mode 100644 index 0000000..b2a60fc --- /dev/null +++ b/lib/plotScene.ts @@ -0,0 +1,409 @@ +import { + calculateRingLayout, + getColorIntensity, + hexToRGB, + positionToAngle, + type RingLayout, +} from './geometry'; +import { collectReferenceAnnotations } from './referenceAnnotations'; +import type { + Annotation, + CircularPlotData, + ContigBoundary, + PlotViewState, + PipelineParams, + RingData, +} from './types'; +import type { PlotTooltip, RenderConfig } from './rendering/types'; +import { + buildGCLegendScene, + buildRingLegendScene, + type LegendScene, +} from './rendering/legendLayout'; + +export interface ArcScene { + startAngle: number; + endAngle: number; + innerRadius: number; + outerRadius: number; + fill: string; + opacity: number; + tooltip: PlotTooltip; +} + +export interface MetricArcScene extends ArcScene { + value: string; +} + +export interface MetricRingScene { + layout: RingLayout; + baseRadius: number; + arcs: readonly MetricArcScene[]; +} + +export interface AlignmentArcScene extends ArcScene { + start: number; + end: number; + identity: number; + alignmentLength: number; +} + +export interface GraphArcScene extends ArcScene { + start: number; + end: number; + value: number; +} + +export interface ContigArcScene extends ArcScene { + name: string; + start: number; + end: number; + label: { x: number; y: number; text: string; fontSize: number } | null; +} + +export interface PlotRingScene { + ring: RingData; + layout: RingLayout; + annotations: readonly Annotation[]; + alignmentArcs: readonly AlignmentArcScene[]; + graphArcs: readonly GraphArcScene[]; +} + +export interface PlotScene { + width: number; + height: number; + centerX: number; + centerY: number; + referenceLength: number; + referenceAnnotations: readonly Annotation[]; + gcContent: MetricRingScene | null; + gcSkew: MetricRingScene | null; + rings: readonly PlotRingScene[]; + contigs: readonly ContigBoundary[]; + contigLayout: RingLayout | null; + contigArcs: readonly ContigArcScene[]; + scaleMarkers: ReadonlyArray<{ + position: number; + line: { x1: number; y1: number; x2: number; y2: number }; + label: { x: number; y: number; text: string }; + }>; + gcLegend: LegendScene | null; + ringLegend: LegendScene | null; + viewState: PlotViewState; +} + +const DEFAULT_VIEW_STATE: PlotViewState = { + zoom: 1, + panX: 0, + panY: 0, + gcLegendPos: null, + ringLegendPos: null, +}; + +/** Apply presentation-only controls without discarding analysis data. */ +export function selectDisplayedPlotData( + data: CircularPlotData, + params: Pick, +): CircularPlotData { + return { + ...data, + reference: { + ...data.reference, + gcContent: params.showGCContent !== false ? data.reference.gcContent : undefined, + gcSkew: params.showGCSkew !== false ? data.reference.gcSkew : undefined, + }, + }; +} + +function buildMetricRing( + values: readonly number[] | undefined, + layout: RingLayout | null, + referenceLength: number, + kind: 'gc-content' | 'gc-skew', +): MetricRingScene | null { + if (!values || !layout) return null; + const baseRadius = layout.radius + layout.width / 2; + const maxBarHeight = layout.width / 2; + const windowSize = referenceLength / values.length; + const scaleValues = kind === 'gc-content' + ? values.map(value => Math.abs(value - 0.5)).sort((left, right) => left - right) + : values.map(Math.abs).sort((left, right) => left - right); + const percentile95 = scaleValues[Math.floor(scaleValues.length * 0.95)] || 0.1; + const scaleFactor = percentile95 > 0 + ? (kind === 'gc-content' ? 0.5 : 1) / percentile95 + : 1; + + const arcs = values.map((value, index): MetricArcScene => { + const start = index * windowSize; + const end = (index + 1) * windowSize; + const signedValue = kind === 'gc-content' ? value - 0.5 : value; + const barHeight = Math.min(maxBarHeight, Math.abs(signedValue) * scaleFactor * maxBarHeight); + const innerRadius = signedValue >= 0 ? baseRadius : baseRadius - barHeight; + const outerRadius = signedValue >= 0 ? baseRadius + barHeight : baseRadius; + const formattedValue = kind === 'gc-content' + ? (value * 100).toFixed(1) + : value.toFixed(3); + const fill = kind === 'gc-content' + ? `rgb(${Math.floor((1 - value) * 200 + 55)}, ${Math.floor(value * 200 + 55)}, 50)` + : value >= 0 ? '#22c55e' : '#a855f7'; + + return { + startAngle: positionToAngle(start, referenceLength), + endAngle: positionToAngle(end, referenceLength), + innerRadius, + outerRadius, + fill, + opacity: 0.8, + value: formattedValue, + tooltip: { + type: kind, + [kind === 'gc-content' ? 'gc' : 'skew']: formattedValue, + position: Math.floor(start), + windowSize: Math.floor(windowSize), + }, + }; + }); + + return { layout, baseRadius, arcs }; +} + +function buildAlignmentArcs( + ring: RingData, + layout: RingLayout, + referenceLength: number, + config: RenderConfig, +): AlignmentArcScene[] { + const sortedHits = [...ring.hits] + .sort((left, right) => (right.refEnd - right.refStart) - (left.refEnd - left.refStart)); + const drawnRegions: Array<{ start: number; end: number }> = []; + const arcs: AlignmentArcScene[] = []; + + for (const hit of sortedHits) { + if (drawnRegions.some(region => hit.refStart >= region.start && hit.refEnd <= region.end)) continue; + arcs.push({ + startAngle: positionToAngle(hit.refStart, referenceLength), + endAngle: positionToAngle(hit.refEnd, referenceLength), + innerRadius: layout.radius, + outerRadius: layout.radius + layout.width, + fill: getColorIntensity( + ring.color, + hit.percentIdentity, + ring.lowerThreshold ?? config.minIdentity, + ring.upperThreshold ?? config.maxIdentity, + ), + opacity: 1, + start: hit.refStart, + end: hit.refEnd, + identity: hit.percentIdentity, + alignmentLength: hit.alignmentLength, + tooltip: { + type: 'alignment', + queryName: ring.queryName, + start: hit.refStart, + end: hit.refEnd, + identity: hit.percentIdentity, + coverage: 1, + }, + }); + drawnRegions.push({ start: hit.refStart, end: hit.refEnd }); + } + return arcs; +} + +function buildGraphArcs( + ring: RingData, + layout: RingLayout, + referenceLength: number, +): GraphArcScene[] { + const capValue = ring.graphMaxCap || ring.graphMaxValue || 1; + const { r, g, b } = hexToRGB(ring.color); + return (ring.graphPoints ?? []) + .filter(point => point.value > 0) + .map((point): GraphArcScene => { + const barHeight = Math.min(1, point.value / capValue) * layout.width; + return { + startAngle: positionToAngle(point.start, referenceLength), + endAngle: positionToAngle(point.end, referenceLength), + innerRadius: layout.radius, + outerRadius: layout.radius + barHeight, + fill: ring.graphMaxCap != null && point.value > capValue + ? 'rgb(30, 100, 220)' + : `rgb(${r}, ${g}, ${b})`, + opacity: 0.8, + start: point.start, + end: point.end, + value: point.value, + tooltip: { + type: 'graph', + queryName: ring.queryName, + start: point.start, + end: point.end, + value: point.value.toFixed(2), + }, + }; + }); +} + +function buildContigArcs( + contigs: readonly ContigBoundary[], + layout: RingLayout | null, + referenceLength: number, + centerX: number, + centerY: number, + scaleFontSize: number, +): ContigArcScene[] { + if (!layout) return []; + const colors = ['#ef4444', '#3b82f6']; + return contigs.map((contig): ContigArcScene => { + const startAngle = positionToAngle(contig.start, referenceLength); + const endAngle = positionToAngle(contig.end, referenceLength); + const color = colors[contig.index % colors.length]; + const labelRadius = layout.radius + layout.width + 8; + const midAngle = (startAngle + endAngle) / 2; + const showLabel = (contig.end - contig.start) / referenceLength > 0.02; + return { + startAngle, + endAngle, + innerRadius: layout.radius, + outerRadius: layout.radius + layout.width, + fill: color, + opacity: 0.6, + name: contig.name, + start: contig.start, + end: contig.end, + label: showLabel ? { + x: centerX + labelRadius * Math.cos(midAngle), + y: centerY + labelRadius * Math.sin(midAngle), + text: contig.name.length > 15 ? `${contig.name.substring(0, 12)}...` : contig.name, + fontSize: Math.max(7, scaleFontSize - 2), + } : null, + tooltip: { + type: 'contig', + name: contig.name, + start: contig.start, + end: contig.end, + length: contig.end - contig.start, + }, + }; + }); +} + +function buildScaleMarkers( + centerX: number, + centerY: number, + referenceLength: number, + radius: number, +): PlotScene['scaleMarkers'] { + return Array.from({ length: 12 }, (_, index) => { + const angle = (index / 12) * 2 * Math.PI - Math.PI / 2; + const position = Math.floor((index / 12) * referenceLength); + const tickLength = index === 3 || index === 9 ? 8 : 18; + return { + position, + line: { + x1: centerX + (radius - tickLength) * Math.cos(angle), + y1: centerY + (radius - tickLength) * Math.sin(angle), + x2: centerX + (radius + 3) * Math.cos(angle), + y2: centerY + (radius + 3) * Math.sin(angle), + }, + label: { + x: centerX + (radius - 30) * Math.cos(angle), + y: centerY + (radius - 30) * Math.sin(angle), + text: `${(position / 1000).toFixed(0)}kb`, + }, + }; + }); +} + +/** + * Build the renderer-independent track plan for a plot. + * + * Canvas and SVG deliberately remain separate painting backends, but neither + * backend should independently decide which tracks exist or where they sit. + */ +export function buildPlotScene( + data: CircularPlotData, + config: RenderConfig, + viewState: PlotViewState = DEFAULT_VIEW_STATE, +): PlotScene { + const visibleRings = (data.rings ?? []).filter(ring => ring.visible); + const contigs = data.reference.contigs && data.reference.contigs.length > 1 + ? data.reference.contigs + : []; + const layouts = calculateRingLayout( + { + innerRadius: config.innerRadius, + ringWidth: config.ringWidth, + gcRingWidth: config.gcRingWidth, + ringSpacing: config.ringSpacing, + }, + { + hasGCContent: Boolean(data.reference.gcContent), + hasGCSkew: Boolean(data.reference.gcSkew), + rings: visibleRings, + hasContigs: contigs.length > 0, + }, + ); + + const layoutFor = (type: RingLayout['type']) => ( + layouts.find(layout => layout.type === type) ?? null + ); + const gcContentLayout = layoutFor('gc-content'); + const gcSkewLayout = layoutFor('gc-skew'); + const centerX = config.width / 2; + const centerY = config.height / 2; + const referenceLength = data.reference.length; + const contigLayout = layoutFor('contig'); + + return { + width: config.width, + height: config.height, + centerX, + centerY, + referenceLength, + referenceAnnotations: collectReferenceAnnotations( + data.reference.features, + data.reference.annotations, + ), + gcContent: buildMetricRing(data.reference.gcContent, gcContentLayout, referenceLength, 'gc-content'), + gcSkew: buildMetricRing(data.reference.gcSkew, gcSkewLayout, referenceLength, 'gc-skew'), + rings: visibleRings.map(ring => { + const layout = layouts.find(candidate => candidate.queryId === ring.queryId); + if (!layout) throw new Error(`Missing layout for visible ring ${ring.queryId}`); + return { + ring, + layout, + annotations: ring.annotations ?? [], + alignmentArcs: layout.type === 'alignment' + ? buildAlignmentArcs(ring, layout, referenceLength, config) + : [], + graphArcs: layout.type === 'graph' + ? buildGraphArcs(ring, layout, referenceLength) + : [], + }; + }), + contigs, + contigLayout, + contigArcs: buildContigArcs( + contigs, + contigLayout, + referenceLength, + centerX, + centerY, + config.scaleFontSize, + ), + scaleMarkers: buildScaleMarkers(centerX, centerY, referenceLength, config.innerRadius), + gcLegend: config.showLegend !== false && (data.reference.gcContent || data.reference.gcSkew) + ? buildGCLegendScene( + config, + viewState.gcLegendPos, + Boolean(data.reference.gcContent), + Boolean(data.reference.gcSkew), + ) + : null, + ringLegend: config.showLegend !== false && visibleRings.length > 0 + ? buildRingLegendScene(config, viewState.ringLegendPos, visibleRings) + : null, + viewState, + }; +} diff --git a/lib/renderer.ts b/lib/renderer.ts index 54033e3..b3a24f0 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -1,11 +1,17 @@ // Circular Plot SVG Renderer -import type { CircularPlotData, RingData, Annotation, ContigBoundary, PlotViewState } from './types'; -import { collectReferenceAnnotations } from './referenceAnnotations'; -import { positionToAngle, createArcPath as geometryCreateArcPath, getColorIntensity as geometryGetColorIntensity } from './geometry'; +import type { CircularPlotData, Annotation, PlotViewState } from './types'; +import { createArcPath as geometryCreateArcPath } from './geometry'; +import { + buildPlotScene, + type ContigArcScene, + type MetricRingScene, + type PlotRingScene, + type PlotScene, +} from './plotScene'; import type { RenderConfig } from './rendering/types'; import type { TooltipCallback } from './rendering/types'; import { renderSVGAnnotations } from './rendering/svgAnnotations'; -import { renderSVGGCLegend, renderSVGRingLegend } from './rendering/svgLegends'; +import { renderSVGLegend } from './rendering/svgLegends'; export type { RenderConfig } from './rendering/types'; @@ -13,8 +19,6 @@ export class CircularPlotRenderer { private svg: SVGSVGElement | null = null; private config: RenderConfig; private tooltipCallback?: TooltipCallback; - private gcLegendPos: { x: number; y: number } | null = null; - private ringLegendPos: { x: number; y: number } | null = null; constructor(config: RenderConfig) { this.config = config; @@ -55,542 +59,222 @@ export class CircularPlotRenderer { svg.appendChild(group); } - private renderGCSkewRing( + private renderMetricRing( svg: SVGElement, cx: number, cy: number, - refLength: number, - gcSkew: number[], - ringRadius: number - ) { + scene: MetricRingScene, + ): void { + const isGCContent = scene.layout.type === 'gc-content'; const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - group.setAttribute('id', 'gc-skew-ring'); - group.setAttribute('inkscape:label', 'GC Skew'); - group.setAttribute('class', 'gc-skew-ring'); - - // Position GC skew as its own ring - const ringWidth = this.config.gcRingWidth; - const baseRadius = ringRadius + ringWidth / 2; // Middle of the ring - const maxBarHeight = ringWidth / 2; // Maximum height is half the ring width - const windowSize = refLength / gcSkew.length; - - // Use 95th percentile for scaling (avoids outliers) - const skewAbs = gcSkew.map(Math.abs).sort((a, b) => a - b); - const p95Skew = skewAbs[Math.floor(skewAbs.length * 0.95)] || 0.1; - const scaleFactor = p95Skew > 0 ? 1 / p95Skew : 1; - - // Draw baseline circle (center of ring) + group.setAttribute('id', isGCContent ? 'gc-content-ring' : 'gc-skew-ring'); + group.setAttribute('inkscape:label', isGCContent ? 'GC Content' : 'GC Skew'); + group.setAttribute('class', isGCContent ? 'gc-ring' : 'gc-skew-ring'); + const baseline = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); baseline.setAttribute('cx', String(cx)); baseline.setAttribute('cy', String(cy)); - baseline.setAttribute('r', String(baseRadius)); + baseline.setAttribute('r', String(scene.baseRadius)); baseline.setAttribute('fill', 'none'); baseline.setAttribute('stroke', '#999'); baseline.setAttribute('stroke-width', '1'); baseline.setAttribute('stroke-dasharray', '3,3'); group.appendChild(baseline); - - // Draw ring boundaries - const innerCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - innerCircle.setAttribute('cx', String(cx)); - innerCircle.setAttribute('cy', String(cy)); - innerCircle.setAttribute('r', String(ringRadius)); - innerCircle.setAttribute('fill', 'none'); - innerCircle.setAttribute('stroke', '#ccc'); - innerCircle.setAttribute('stroke-width', '1'); - group.appendChild(innerCircle); - - const outerCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - outerCircle.setAttribute('cx', String(cx)); - outerCircle.setAttribute('cy', String(cy)); - outerCircle.setAttribute('r', String(ringRadius + ringWidth)); - outerCircle.setAttribute('fill', 'none'); - outerCircle.setAttribute('stroke', '#ccc'); - outerCircle.setAttribute('stroke-width', '1'); - group.appendChild(outerCircle); - - gcSkew.forEach((skew, i) => { - const start = i * windowSize; - const end = (i + 1) * windowSize; - const startAngle = positionToAngle(start, refLength); - const endAngle = positionToAngle(end, refLength); - - // Calculate bar height based on GC skew (ranges from -1 to +1) - // Scale to use full ring height based on actual data range - const barHeight = Math.min(maxBarHeight, Math.abs(skew) * scaleFactor * maxBarHeight); - - let innerRadius: number; - let outerRadius: number; - let color: string; - - if (skew >= 0) { - // Positive skew (more G): Green, extends outward - innerRadius = baseRadius; - outerRadius = baseRadius + barHeight; - color = '#22c55e'; // Green - } else { - // Negative skew (more C): Purple, extends inward - innerRadius = baseRadius - barHeight; - outerRadius = baseRadius; - color = '#a855f7'; // Purple - } - - const path = this.createArcPath( - cx, - cy, - innerRadius, - outerRadius, - startAngle, - endAngle - ); - - const arcElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - arcElement.setAttribute('d', path); - arcElement.setAttribute('fill', color); - arcElement.setAttribute('stroke', 'none'); - arcElement.setAttribute('opacity', '0.8'); - - // Add tooltip on hover - const skewFormatted = skew.toFixed(3); - arcElement.setAttribute('data-gc-skew', skewFormatted); - arcElement.style.cursor = 'pointer'; - arcElement.addEventListener('mouseenter', (_e) => { - arcElement.setAttribute('opacity', '1.0'); - if (this.tooltipCallback) { - this.tooltipCallback({ - type: 'gc-skew', - skew: skewFormatted, - position: Math.floor(start), - windowSize: Math.floor(windowSize) - }); - } - }); - - arcElement.addEventListener('mouseleave', () => { - arcElement.setAttribute('opacity', '0.8'); - }); - - group.appendChild(arcElement); - }); - - svg.appendChild(group); - } + for (const radius of [scene.layout.radius, scene.layout.radius + scene.layout.width]) { + const boundary = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + boundary.setAttribute('cx', String(cx)); + boundary.setAttribute('cy', String(cy)); + boundary.setAttribute('r', String(radius)); + boundary.setAttribute('fill', 'none'); + boundary.setAttribute('stroke', '#ccc'); + boundary.setAttribute('stroke-width', '1'); + group.appendChild(boundary); + } - private renderGCRing( - svg: SVGElement, - cx: number, - cy: number, - refLength: number, - gcContent: number[], - ringRadius: number - ) { - const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - group.setAttribute('id', 'gc-content-ring'); - group.setAttribute('inkscape:label', 'GC Content'); - group.setAttribute('class', 'gc-ring'); - - // Position GC ring as its own ring outside reference - const ringWidth = this.config.gcRingWidth; - const baseRadius = ringRadius + ringWidth / 2; // Middle of the ring (50% baseline) - const maxBarHeight = ringWidth / 2; // Maximum height is half the ring width - const windowSize = refLength / gcContent.length; - - // Use 95th percentile deviation for scaling (avoids outliers like N-spacer windows) - const deviations = gcContent.map(gc => Math.abs(gc - 0.5)).sort((a, b) => a - b); - const p95 = deviations[Math.floor(deviations.length * 0.95)] || 0.1; - const scaleFactor = p95 > 0 ? 0.5 / p95 : 1; - - // Draw baseline circle (center of ring - 50% GC) - const baseline = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - baseline.setAttribute('cx', String(cx)); - baseline.setAttribute('cy', String(cy)); - baseline.setAttribute('r', String(baseRadius)); - baseline.setAttribute('fill', 'none'); - baseline.setAttribute('stroke', '#999'); - baseline.setAttribute('stroke-width', '1'); - baseline.setAttribute('stroke-dasharray', '3,3'); - group.appendChild(baseline); - - // Draw ring boundaries - const innerCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - innerCircle.setAttribute('cx', String(cx)); - innerCircle.setAttribute('cy', String(cy)); - innerCircle.setAttribute('r', String(ringRadius)); - innerCircle.setAttribute('fill', 'none'); - innerCircle.setAttribute('stroke', '#ccc'); - innerCircle.setAttribute('stroke-width', '1'); - group.appendChild(innerCircle); - - const outerCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - outerCircle.setAttribute('cx', String(cx)); - outerCircle.setAttribute('cy', String(cy)); - outerCircle.setAttribute('r', String(ringRadius + ringWidth)); - outerCircle.setAttribute('fill', 'none'); - outerCircle.setAttribute('stroke', '#ccc'); - outerCircle.setAttribute('stroke-width', '1'); - group.appendChild(outerCircle); - - gcContent.forEach((gc, i) => { - const start = i * windowSize; - const end = (i + 1) * windowSize; - const startAngle = positionToAngle(start, refLength); - const endAngle = positionToAngle(end, refLength); - - // Calculate bar height based on GC content deviation from 50% - // Scale to use full ring height based on actual data range - const deviation = gc - 0.5; // Range: -0.5 to +0.5 - const barHeight = Math.min(maxBarHeight, Math.abs(deviation) * scaleFactor * maxBarHeight); - - let innerRadius: number; - let outerRadius: number; - - if (deviation >= 0) { - // High GC (>50%): extends outward - innerRadius = baseRadius; - outerRadius = baseRadius + barHeight; - } else { - // Low GC (<50%): extends inward - innerRadius = baseRadius - barHeight; - outerRadius = baseRadius; - } - - const path = this.createArcPath( + for (const arc of scene.arcs) { + const arcElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + arcElement.setAttribute('d', this.createArcPath( cx, cy, - innerRadius, - outerRadius, - startAngle, - endAngle - ); - - const arcElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - arcElement.setAttribute('d', path); - - // Color gradient: low GC (red) to high GC (green) - const r = Math.floor((1 - gc) * 200 + 55); - const g = Math.floor(gc * 200 + 55); - const b = 50; - arcElement.setAttribute('fill', `rgb(${r}, ${g}, ${b})`); + arc.innerRadius, + arc.outerRadius, + arc.startAngle, + arc.endAngle, + )); + arcElement.setAttribute('fill', arc.fill); arcElement.setAttribute('stroke', 'none'); - arcElement.setAttribute('opacity', '0.8'); - - // Add tooltip on hover - const gcPercent = (gc * 100).toFixed(1); - arcElement.setAttribute('data-gc', gcPercent); + arcElement.setAttribute('opacity', String(arc.opacity)); + arcElement.setAttribute(isGCContent ? 'data-gc' : 'data-gc-skew', arc.value); arcElement.style.cursor = 'pointer'; - - arcElement.addEventListener('mouseenter', (_e) => { + arcElement.addEventListener('mouseenter', () => { arcElement.setAttribute('opacity', '1.0'); - if (this.tooltipCallback) { - this.tooltipCallback({ - type: 'gc-content', - gc: gcPercent, - position: Math.floor(start), - windowSize: Math.floor(windowSize) - }); - } + this.tooltipCallback?.(arc.tooltip); }); - arcElement.addEventListener('mouseleave', () => { - arcElement.setAttribute('opacity', '0.8'); + arcElement.setAttribute('opacity', String(arc.opacity)); + this.tooltipCallback?.(null); }); - group.appendChild(arcElement); - }); - + } + svg.appendChild(group); } - private renderGraphRing( svg: SVGElement, cx: number, cy: number, - refLength: number, - ring: RingData, - radius: number, - ringWidth?: number - ) { - const width = ringWidth || this.config.ringWidth; + track: PlotRingScene, + ): void { + const { ring, layout } = track; const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); group.setAttribute('id', `ring-graph-${ring.queryId}`); group.setAttribute('inkscape:label', `Graph: ${ring.queryName}`); group.setAttribute('class', `ring ring-graph-${ring.queryId}`); group.setAttribute('data-query-id', ring.queryId); - const points = ring.graphPoints!; - // Use graphMaxCap if set, otherwise use data max - const capValue = ring.graphMaxCap || ring.graphMaxValue || 1; - - // Draw ring boundary (outer) - const outerCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - outerCircle.setAttribute('cx', String(cx)); - outerCircle.setAttribute('cy', String(cy)); - outerCircle.setAttribute('r', String(radius + width)); - outerCircle.setAttribute('fill', 'none'); - outerCircle.setAttribute('stroke', '#ccc'); - outerCircle.setAttribute('stroke-width', '0.5'); - group.appendChild(outerCircle); - - // Draw ring boundary (inner) - const innerCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - innerCircle.setAttribute('cx', String(cx)); - innerCircle.setAttribute('cy', String(cy)); - innerCircle.setAttribute('r', String(radius)); - innerCircle.setAttribute('fill', 'none'); - innerCircle.setAttribute('stroke', '#ccc'); - innerCircle.setAttribute('stroke-width', '0.5'); - group.appendChild(innerCircle); - - // Parse the ring color - const hex = ring.color.replace('#', ''); - const cr = parseInt(hex.substring(0, 2), 16); - const cg = parseInt(hex.substring(2, 4), 16); - const cb = parseInt(hex.substring(4, 6), 16); - - // Render each point as a filled arc proportional to value/capValue - // Values above cap are clamped to full height and shown in blue - for (const point of points) { - if (point.value <= 0) continue; - - const startAngle = positionToAngle(point.start, refLength); - const endAngle = positionToAngle(point.end, refLength); - - const isOverCap = ring.graphMaxCap != null && point.value > capValue; - const fraction = Math.min(1, point.value / capValue); - const barHeight = fraction * width; - - const path = this.createArcPath( - cx, cy, - radius, - radius + barHeight, - startAngle, - endAngle - ); + for (const radius of [layout.radius, layout.radius + layout.width]) { + const boundary = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + boundary.setAttribute('cx', String(cx)); + boundary.setAttribute('cy', String(cy)); + boundary.setAttribute('r', String(radius)); + boundary.setAttribute('fill', 'none'); + boundary.setAttribute('stroke', '#ccc'); + boundary.setAttribute('stroke-width', '0.5'); + group.appendChild(boundary); + } + for (const arc of track.graphArcs) { const arcElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - arcElement.setAttribute('d', path); - // Blue for over-cap, ring colour for normal - arcElement.setAttribute('fill', isOverCap ? 'rgb(30, 100, 220)' : `rgb(${cr}, ${cg}, ${cb})`); + arcElement.setAttribute('d', this.createArcPath( + cx, + cy, + arc.innerRadius, + arc.outerRadius, + arc.startAngle, + arc.endAngle, + )); + arcElement.setAttribute('fill', arc.fill); arcElement.setAttribute('stroke', 'none'); - arcElement.setAttribute('opacity', '0.8'); - - // Tooltip + arcElement.setAttribute('opacity', String(arc.opacity)); arcElement.style.cursor = 'pointer'; arcElement.addEventListener('mouseenter', () => { arcElement.setAttribute('opacity', '1.0'); - if (this.tooltipCallback) { - this.tooltipCallback({ - type: 'graph', - queryName: ring.queryName, - start: point.start, - end: point.end, - value: point.value.toFixed(2) - }); - } + this.tooltipCallback?.(arc.tooltip); }); arcElement.addEventListener('mouseleave', () => { - arcElement.setAttribute('opacity', '0.8'); - if (this.tooltipCallback) { - this.tooltipCallback(null); - } + arcElement.setAttribute('opacity', String(arc.opacity)); + this.tooltipCallback?.(null); }); - group.appendChild(arcElement); } svg.appendChild(group); } - private renderContigBoundaries( svg: SVGElement, cx: number, cy: number, - refLength: number, - contigs: ContigBoundary[], - radius: number, - ringWidth: number - ) { + arcs: readonly ContigArcScene[], + ): void { const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); group.setAttribute('id', 'contig-boundaries'); group.setAttribute('inkscape:label', 'Contig Boundaries'); group.setAttribute('class', 'contig-boundaries'); - const colors = ['#ef4444', '#3b82f6']; // Alternating red/blue - - for (const contig of contigs) { - const startAngle = positionToAngle(contig.start, refLength); - const endAngle = positionToAngle(contig.end, refLength); - const color = colors[contig.index % 2]; - - const path = this.createArcPath( - cx, cy, - radius, - radius + ringWidth, - startAngle, - endAngle - ); - + for (const arc of arcs) { const arcElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - arcElement.setAttribute('d', path); - arcElement.setAttribute('fill', color); + arcElement.setAttribute('d', this.createArcPath( + cx, + cy, + arc.innerRadius, + arc.outerRadius, + arc.startAngle, + arc.endAngle, + )); + arcElement.setAttribute('fill', arc.fill); arcElement.setAttribute('stroke', 'none'); - arcElement.setAttribute('opacity', '0.6'); - + arcElement.setAttribute('opacity', String(arc.opacity)); arcElement.style.cursor = 'pointer'; arcElement.addEventListener('mouseenter', () => { arcElement.setAttribute('opacity', '0.9'); - if (this.tooltipCallback) { - this.tooltipCallback({ - type: 'contig', - name: contig.name, - start: contig.start, - end: contig.end, - length: contig.end - contig.start - }); - } + this.tooltipCallback?.(arc.tooltip); }); arcElement.addEventListener('mouseleave', () => { - arcElement.setAttribute('opacity', '0.6'); - if (this.tooltipCallback) { - this.tooltipCallback(null); - } + arcElement.setAttribute('opacity', String(arc.opacity)); + this.tooltipCallback?.(null); }); - group.appendChild(arcElement); - // Add contig label - const midAngle = (startAngle + endAngle) / 2; - const labelRadius = radius + ringWidth + 8; - const lx = cx + labelRadius * Math.cos(midAngle); - const ly = cy + labelRadius * Math.sin(midAngle); - - // Only add label if the arc is large enough - const arcSpan = contig.end - contig.start; - if (arcSpan / refLength > 0.02) { + if (arc.label) { const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - label.setAttribute('x', String(lx)); - label.setAttribute('y', String(ly)); + label.setAttribute('x', String(arc.label.x)); + label.setAttribute('y', String(arc.label.y)); label.setAttribute('text-anchor', 'middle'); label.setAttribute('dominant-baseline', 'middle'); - label.setAttribute('font-size', String(Math.max(7, this.config.scaleFontSize - 2))); - label.setAttribute('fill', color); + label.setAttribute('font-size', String(arc.label.fontSize)); + label.setAttribute('fill', arc.fill); label.setAttribute('font-weight', 'bold'); - - // Truncate long names - const displayName = contig.name.length > 15 ? contig.name.substring(0, 12) + '...' : contig.name; - label.textContent = displayName; + label.textContent = arc.label.text; group.appendChild(label); } } svg.appendChild(group); } - private renderQueryRing( svg: SVGElement, cx: number, cy: number, - refLength: number, - ring: RingData, - radius: number, - ringWidth?: number - ) { - const width = ringWidth || this.config.ringWidth; + track: PlotRingScene, + ): void { + const { ring } = track; const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); group.setAttribute('id', `ring-${ring.queryId}`); group.setAttribute('inkscape:label', `Ring: ${ring.queryName}`); group.setAttribute('class', `ring ring-${ring.queryId}`); group.setAttribute('data-query-id', ring.queryId); - - // Use direct hit rendering instead of windows - if (ring.hits && ring.hits.length > 0) { - // Sort hits by size (largest first, so smallest drawn last and appear on top) - const sortedHits = [...ring.hits].sort((a, b) => { - const sizeA = a.refEnd - a.refStart; - const sizeB = b.refEnd - b.refStart; - return sizeB - sizeA; // Descending order + + for (const arc of track.alignmentArcs) { + const arcElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + arcElement.setAttribute('d', this.createArcPath( + cx, + cy, + arc.innerRadius, + arc.outerRadius, + arc.startAngle, + arc.endAngle, + )); + arcElement.setAttribute('fill', arc.fill); + arcElement.setAttribute('stroke', 'none'); + arcElement.setAttribute('data-start', String(arc.start)); + arcElement.setAttribute('data-end', String(arc.end)); + arcElement.setAttribute('data-identity', arc.identity.toFixed(1)); + arcElement.setAttribute('data-length', String(arc.alignmentLength)); + arcElement.addEventListener('mouseenter', event => { + arcElement.setAttribute('stroke', '#000'); + arcElement.setAttribute('stroke-width', '1'); + this.tooltipCallback?.({ + ...arc.tooltip, + x: event.clientX, + y: event.clientY, + }); }); - - // Track drawn regions for occlusion detection - const drawnRegions: Array<{ start: number; end: number }> = []; - - sortedHits.forEach(hit => { - // Check if this hit would be completely occluded - const isOccluded = drawnRegions.some(region => - hit.refStart >= region.start && hit.refEnd <= region.end - ); - - if (isOccluded) { - return; // Skip this hit - } - - // Calculate angles for this hit - const startAngle = positionToAngle(hit.refStart, refLength); - const endAngle = positionToAngle(hit.refEnd, refLength); - - const path = this.createArcPath( - cx, - cy, - radius, - radius + width, - startAngle, - endAngle - ); - - // Get color intensity based on percent identity and thresholds - const color = this.getColorIntensity(ring.color, hit.percentIdentity, ring.lowerThreshold, ring.upperThreshold); - - const arcElement = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - arcElement.setAttribute('d', path); - arcElement.setAttribute('fill', color); + arcElement.addEventListener('mouseleave', () => { arcElement.setAttribute('stroke', 'none'); - arcElement.setAttribute('data-start', String(hit.refStart)); - arcElement.setAttribute('data-end', String(hit.refEnd)); - arcElement.setAttribute('data-identity', String(hit.percentIdentity.toFixed(1))); - arcElement.setAttribute('data-length', String(hit.alignmentLength)); - - // Add hover effect - arcElement.addEventListener('mouseenter', (e) => { - arcElement.setAttribute('stroke', '#000'); - arcElement.setAttribute('stroke-width', '1'); - - if (this.tooltipCallback) { - this.tooltipCallback({ - queryName: ring.queryName, - start: hit.refStart, - end: hit.refEnd, - identity: hit.percentIdentity, - coverage: 1.0, - x: (e as MouseEvent).clientX, - y: (e as MouseEvent).clientY - }); - } - }); - - arcElement.addEventListener('mouseleave', () => { - arcElement.setAttribute('stroke', 'none'); - if (this.tooltipCallback) { - this.tooltipCallback(null); - } - }); - - group.appendChild(arcElement); - - // Track this region as drawn - drawnRegions.push({ start: hit.refStart, end: hit.refEnd }); + this.tooltipCallback?.(null); }); + group.appendChild(arcElement); } - + svg.appendChild(group); } - private renderAnnotations( parent: SVGElement, centerX: number, centerY: number, referenceLength: number, - annotations: Annotation[], + annotations: readonly Annotation[], innerRadius: number, outerRadius: number, showLabels = true, @@ -614,100 +298,40 @@ export class CircularPlotRenderer { tooltipCallback: this.tooltipCallback, }); } - // Color intensity scaling based on identity and thresholds (delegates to shared geometry utility) - private getColorIntensity(baseColor: string, percentIdentity: number, lowerThreshold?: number, upperThreshold?: number): string { - const lower = lowerThreshold ?? this.config.minIdentity; - const upper = upperThreshold ?? 100; - return geometryGetColorIntensity(baseColor, percentIdentity, lower, upper); - } - - private renderGCLegend(parent: SVGElement, hasGCContent: boolean, hasGCSkew: boolean): void { - const definitions = this.svg?.querySelector('#defs'); - if (!definitions) throw new Error('SVG definitions are unavailable'); - renderSVGGCLegend( - parent, - definitions, - this.config, - this.gcLegendPos, - hasGCContent, - hasGCSkew, - ); - } private renderScaleMarkers( svg: SVGElement, - cx: number, - cy: number, - refLength: number - ) { + markers: PlotScene['scaleMarkers'], + ): void { const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); group.setAttribute('id', 'scale-markers'); group.setAttribute('inkscape:label', 'Scale Markers'); group.setAttribute('class', 'scale-markers'); - - const numMarkers = 12; - const markerRadius = this.config.innerRadius; - - for (let i = 0; i < numMarkers; i++) { - const angle = (i / numMarkers) * 2 * Math.PI - Math.PI / 2; - const position = Math.floor((i / numMarkers) * refLength); - - // Check if this is 3 o'clock (i=3) or 9 o'clock (i=9) position - const is3or9 = (i === 3 || i === 9); - const tickLength = is3or9 ? 8 : 18; // Shorter ticks at 3 and 9 o'clock - - // Marker line (pointing inward) - const x1 = cx + (markerRadius - tickLength) * Math.cos(angle); - const y1 = cy + (markerRadius - tickLength) * Math.sin(angle); - const x2 = cx + (markerRadius + 3) * Math.cos(angle); - const y2 = cy + (markerRadius + 3) * Math.sin(angle); - + + for (const marker of markers) { const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - line.setAttribute('x1', String(x1)); - line.setAttribute('y1', String(y1)); - line.setAttribute('x2', String(x2)); - line.setAttribute('y2', String(y2)); + line.setAttribute('x1', String(marker.line.x1)); + line.setAttribute('y1', String(marker.line.y1)); + line.setAttribute('x2', String(marker.line.x2)); + line.setAttribute('y2', String(marker.line.y2)); line.setAttribute('stroke', '#666'); line.setAttribute('stroke-width', '2'); - group.appendChild(line); - - // Label (positioned inward) - const labelRadius = markerRadius - 30; - const tx = cx + labelRadius * Math.cos(angle); - const ty = cy + labelRadius * Math.sin(angle); - + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - text.setAttribute('x', String(tx)); - text.setAttribute('y', String(ty)); + text.setAttribute('x', String(marker.label.x)); + text.setAttribute('y', String(marker.label.y)); text.setAttribute('text-anchor', 'middle'); text.setAttribute('dominant-baseline', 'middle'); text.setAttribute('font-size', String(this.config.scaleFontSize)); text.setAttribute('fill', '#333'); - text.textContent = `${(position / 1000).toFixed(0)}kb`; - + text.textContent = marker.label.text; group.appendChild(text); } - - svg.appendChild(group); - } - private renderRingLegend(parent: SVGElement, rings: RingData[]): void { - const definitions = this.svg?.querySelector('#defs'); - if (!definitions) throw new Error('SVG definitions are unavailable'); - renderSVGRingLegend( - parent, - definitions, - this.config, - this.ringLegendPos, - rings, - ); + svg.appendChild(group); } render(container: HTMLElement, data: CircularPlotData, viewState?: PlotViewState): SVGSVGElement { - // Apply legend positions from view state if provided - if (viewState) { - this.gcLegendPos = viewState.gcLegendPos; - this.ringLegendPos = viewState.ringLegendPos; - } + const scene = buildPlotScene(data, this.config, viewState); // Create SVG with Inkscape namespace for layer/group compatibility this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); @@ -747,37 +371,33 @@ export class CircularPlotRenderer { mainGroup.setAttribute('transform-origin', 'center'); // Apply zoom/pan transform if view state is provided (matches canvas renderer's transform) - if (viewState && (viewState.zoom !== 1 || viewState.panX !== 0 || viewState.panY !== 0)) { - const cx = this.config.width / 2; - const cy = this.config.height / 2; + if (scene.viewState.zoom !== 1 || scene.viewState.panX !== 0 || scene.viewState.panY !== 0) { + const cx = scene.centerX; + const cy = scene.centerY; // Replicate the canvas transform: translate(cx + panX, cy + panY) scale(zoom) translate(-cx, -cy) mainGroup.setAttribute('transform', - `translate(${cx + viewState.panX}, ${cy + viewState.panY}) scale(${viewState.zoom}) translate(${-cx}, ${-cy})` + `translate(${cx + scene.viewState.panX}, ${cy + scene.viewState.panY}) scale(${scene.viewState.zoom}) translate(${-cx}, ${-cy})` ); } this.svg.appendChild(mainGroup); - const cx = this.config.width / 2; - const cy = this.config.height / 2; - const refLength = data.reference.length; + const cx = scene.centerX; + const cy = scene.centerY; + const refLength = scene.referenceLength; // Render reference ring first this.renderReferenceRing(mainGroup, cx, cy, refLength); // Reference features sit inside the reference ring, matching the canvas preview. - const referenceAnnotations = collectReferenceAnnotations( - data.reference.features, - data.reference.annotations, - ); - if (referenceAnnotations.length > 0) { + if (scene.referenceAnnotations.length > 0) { const featureInner = Math.max(10, this.config.innerRadius - 30); this.renderAnnotations( mainGroup, cx, cy, refLength, - referenceAnnotations, + scene.referenceAnnotations, featureInner, this.config.innerRadius, true, @@ -787,40 +407,34 @@ export class CircularPlotRenderer { ); } - // Calculate ring positions - GC Content and GC Skew come first - let currentRadius = this.config.innerRadius; - // Render GC Content ring - if (data.reference.gcContent) { - currentRadius += this.config.ringSpacing; // Small spacing from reference - this.renderGCRing(mainGroup, cx, cy, refLength, data.reference.gcContent, currentRadius); - currentRadius += this.config.gcRingWidth + this.config.ringSpacing; + if (scene.gcContent) { + this.renderMetricRing(mainGroup, cx, cy, scene.gcContent); } // Render GC Skew ring (second ring outside reference) - if (data.reference.gcSkew) { - this.renderGCSkewRing(mainGroup, cx, cy, refLength, data.reference.gcSkew, currentRadius); - currentRadius += this.config.gcRingWidth + this.config.ringSpacing; + if (scene.gcSkew) { + this.renderMetricRing(mainGroup, cx, cy, scene.gcSkew); } // Render query rings after GC rings - const visibleRings = data.rings?.filter(r => r.visible) || []; - visibleRings.forEach((ring) => { - const ringWidth = ring.customWidth || this.config.ringWidth; - const radius = currentRadius; + scene.rings.forEach(track => { + const { ring, layout, annotations } = track; + const ringWidth = layout.width; + const radius = layout.radius; // Render as graph ring if it has graph data, otherwise as alignment ring if (ring.graphPoints && ring.graphPoints.length > 0) { - this.renderGraphRing(mainGroup, cx, cy, refLength, ring, radius, ringWidth); + this.renderGraphRing(mainGroup, cx, cy, track); } else { - this.renderQueryRing(mainGroup, cx, cy, refLength, ring, radius, ringWidth); + this.renderQueryRing(mainGroup, cx, cy, track); } - if (ring.annotations && ring.annotations.length > 0) { + if (annotations.length > 0) { this.renderAnnotations( mainGroup, cx, cy, refLength, - ring.annotations, + annotations, radius, radius + ringWidth, ring.showLabels !== false, @@ -829,18 +443,14 @@ export class CircularPlotRenderer { `Annotations: ${ring.queryName}`, ); } - - currentRadius += ringWidth + this.config.ringSpacing; }); // Render contig boundaries on the outermost ring (after all query rings) - if (data.reference.contigs && data.reference.contigs.length > 1) { - const contigRingWidth = 6; - this.renderContigBoundaries(mainGroup, cx, cy, refLength, data.reference.contigs, currentRadius, contigRingWidth); - currentRadius += contigRingWidth + this.config.ringSpacing; + if (scene.contigs.length > 0 && scene.contigLayout) { + this.renderContigBoundaries(mainGroup, cx, cy, scene.contigArcs); } - this.renderScaleMarkers(mainGroup, cx, cy, refLength); + this.renderScaleMarkers(mainGroup, scene.scaleMarkers); // Legends are a separate top-level Inkscape layer. They preserve their // dragged coordinates and do not inherit the map zoom/pan transform. @@ -850,11 +460,17 @@ export class CircularPlotRenderer { legendsGroup.setAttribute('inkscape:label', 'Legends'); legendsGroup.setAttribute('inkscape:groupmode', 'layer'); this.svg.appendChild(legendsGroup); - if (data.reference.gcContent || data.reference.gcSkew) { - this.renderGCLegend(legendsGroup, !!data.reference.gcContent, !!data.reference.gcSkew); + if (scene.gcLegend) { + renderSVGLegend(legendsGroup, topDefs, scene.gcLegend, { + id: 'gc-legend', + label: 'GC Legend', + }); } - if (visibleRings.length > 0) { - this.renderRingLegend(legendsGroup, visibleRings); + if (scene.ringLegend) { + renderSVGLegend(legendsGroup, topDefs, scene.ringLegend, { + id: 'ring-legend', + label: 'Ring Legend', + }); } } diff --git a/lib/rendering/canvasLegends.ts b/lib/rendering/canvasLegends.ts index af53ab9..74c45b8 100644 --- a/lib/rendering/canvasLegends.ts +++ b/lib/rendering/canvasLegends.ts @@ -1,10 +1,10 @@ -import type { RingData } from '../types'; -import { hexToRGB } from '../geometry'; +import { + LEGEND_BAR_HEIGHT, + LEGEND_BAR_WIDTH, + type LegendScene, +} from './legendLayout'; import { roundedRectPath } from './canvasPrimitives'; -import type { LegendBounds, Point, RenderConfig } from './types'; - -const BAR_WIDTH = 120; -const BAR_HEIGHT = 10; +import type { LegendBounds } from './types'; function drawTitle( context: CanvasRenderingContext2D, @@ -22,33 +22,6 @@ function drawTitle( context.restore(); } -function drawTicks( - context: CanvasRenderingContext2D, - ticks: ReadonlyArray<{ label: string; x: number }>, - y: number, - barHeight: number, - fontSize: number, -): void { - for (const tick of ticks) { - context.save(); - context.strokeStyle = '#666'; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(tick.x, y); - context.lineTo(tick.x, y + barHeight + 3); - context.stroke(); - context.restore(); - - context.save(); - context.font = `${fontSize - 3}px sans-serif`; - context.fillStyle = '#666'; - context.textAlign = 'center'; - context.textBaseline = 'alphabetic'; - context.fillText(tick.label, tick.x, y + barHeight + fontSize); - context.restore(); - } -} - function fillRoundedRect( context: CanvasRenderingContext2D, x: number, @@ -70,91 +43,71 @@ function fillRoundedRect( context.restore(); } -export function drawCanvasGCLegend( +export function drawCanvasLegend( context: CanvasRenderingContext2D, - config: RenderConfig, - position: Point | null, - hasGCContent: boolean, - hasGCSkew: boolean, + scene: LegendScene, ): LegendBounds { - const x = position?.x ?? 20; - const startY = position?.y ?? 20; - const fontSize = config.legendFontSize; - let y = startY + fontSize; + for (const section of scene.sections) { + drawTitle(context, section.title, section.titleX, section.titleY, scene.fontSize); - const sections = [ - hasGCContent && { - title: 'GC Content', - colors: ['rgb(255, 55, 50)', 'rgb(55, 255, 50)'], - ticks: ['0%', '50%', '100%'], - }, - hasGCSkew && { - title: 'GC Skew', - colors: ['#a855f7', '#22c55e'], - ticks: ['-1', '0', '+1'], - }, - ].filter((section): section is { title: string; colors: string[]; ticks: string[] } => Boolean(section)); + if (section.kind === 'swatch' && section.swatch) { + fillRoundedRect( + context, + section.swatch.x, + section.swatch.y, + section.swatch.width, + section.swatch.height, + section.swatch.fill, + ); + continue; + } + + if ( + section.kind !== 'gradient' + || !section.colors + || section.barY === undefined + || !section.ticks + || section.tickBottom === undefined + || section.labelY === undefined + ) continue; - for (const section of sections) { - drawTitle(context, section.title, x, y, fontSize); - y += fontSize + 2; - const gradient = context.createLinearGradient(x, y, x + BAR_WIDTH, y); + const gradient = context.createLinearGradient( + scene.x, + section.barY, + scene.x + LEGEND_BAR_WIDTH, + section.barY, + ); gradient.addColorStop(0, section.colors[0]); gradient.addColorStop(1, section.colors[1]); - fillRoundedRect(context, x, y, BAR_WIDTH, BAR_HEIGHT, gradient, true); - drawTicks(context, [ - { label: section.ticks[0], x }, - { label: section.ticks[1], x: x + BAR_WIDTH / 2 }, - { label: section.ticks[2], x: x + BAR_WIDTH }, - ], y, BAR_HEIGHT, fontSize); - y += BAR_HEIGHT + fontSize * 2 + 6; - } - - return { - x: x - 5, - y: startY, - width: BAR_WIDTH + 10, - height: y - startY, - }; -} + fillRoundedRect( + context, + scene.x, + section.barY, + LEGEND_BAR_WIDTH, + LEGEND_BAR_HEIGHT, + gradient, + true, + ); -export function drawCanvasRingLegend( - context: CanvasRenderingContext2D, - config: RenderConfig, - position: Point | null, - rings: readonly RingData[], -): LegendBounds { - const x = position?.x ?? config.width - 200; - const startY = position?.y ?? 20; - const fontSize = config.legendFontSize; - let y = startY + fontSize; + for (const tick of section.ticks) { + context.save(); + context.strokeStyle = '#666'; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(tick.x, section.barY); + context.lineTo(tick.x, section.tickBottom); + context.stroke(); + context.restore(); - for (const ring of rings.filter(candidate => candidate.visible)) { - const hasHits = ring.hits.length > 0; - if (!hasHits) { - fillRoundedRect(context, x, y - fontSize + 3, 12, fontSize, ring.color); - drawTitle(context, ring.queryName, x + 16, y, fontSize); - y += fontSize * 2 + 2; - continue; + context.save(); + context.font = `${scene.fontSize - 3}px sans-serif`; + context.fillStyle = '#666'; + context.textAlign = 'center'; + context.textBaseline = 'alphabetic'; + context.fillText(tick.label, tick.x, section.labelY); + context.restore(); } - - drawTitle(context, ring.queryName, x, y, fontSize); - y += fontSize + 2; - const upper = ring.upperThreshold ?? config.maxIdentity; - const lower = ring.lowerThreshold ?? config.minIdentity; - const { r, g, b } = hexToRGB(ring.color); - const gradient = context.createLinearGradient(x, 0, x + BAR_WIDTH, 0); - gradient.addColorStop(0, `rgb(${Math.round(255 + (r - 255) * 0.15)}, ${Math.round(255 + (g - 255) * 0.15)}, ${Math.round(255 + (b - 255) * 0.15)})`); - gradient.addColorStop(1, ring.color); - const barY = y - BAR_HEIGHT + 2; - fillRoundedRect(context, x, barY, BAR_WIDTH, BAR_HEIGHT, gradient, true); - drawTicks(context, [ - { label: `${lower}%`, x }, - { label: `${Math.round((upper + lower) / 2)}%`, x: x + BAR_WIDTH / 2 }, - { label: `${upper}%`, x: x + BAR_WIDTH }, - ], barY, BAR_HEIGHT - 2, fontSize); - y += fontSize + BAR_HEIGHT + 4 + fontSize; } - return { x: x - 5, y: startY, width: 200, height: y - startY + 5 }; + return scene.bounds; } diff --git a/lib/rendering/legendLayout.ts b/lib/rendering/legendLayout.ts new file mode 100644 index 0000000..9ee4f3a --- /dev/null +++ b/lib/rendering/legendLayout.ts @@ -0,0 +1,151 @@ +import type { RingData } from '../types'; +import { hexToRGB } from '../geometry'; +import type { LegendBounds, Point, RenderConfig } from './types'; + +export const LEGEND_BAR_WIDTH = 120; +export const LEGEND_BAR_HEIGHT = 10; + +export interface LegendTickScene { + x: number; + label: string; +} + +export interface LegendSectionScene { + kind: 'gradient' | 'swatch'; + title: string; + titleX: number; + titleY: number; + colors?: readonly [string, string]; + ticks?: readonly LegendTickScene[]; + barY?: number; + tickBottom?: number; + labelY?: number; + gradientId?: string; + swatch?: { x: number; y: number; width: number; height: number; fill: string }; +} + +export interface LegendScene { + x: number; + fontSize: number; + bounds: LegendBounds; + sections: readonly LegendSectionScene[]; +} + +function ticks(x: number, labels: readonly [string, string, string]): LegendTickScene[] { + return [ + { label: labels[0], x }, + { label: labels[1], x: x + LEGEND_BAR_WIDTH / 2 }, + { label: labels[2], x: x + LEGEND_BAR_WIDTH }, + ]; +} + +export function buildGCLegendScene( + config: RenderConfig, + position: Point | null, + hasGCContent: boolean, + hasGCSkew: boolean, +): LegendScene { + const x = position?.x ?? 20; + const startY = position?.y ?? 20; + const fontSize = config.legendFontSize; + const sections: LegendSectionScene[] = []; + let y = startY + fontSize; + + const addSection = ( + title: string, + colors: readonly [string, string], + labels: readonly [string, string, string], + gradientId: string, + ) => { + const titleY = y; + const barY = titleY + fontSize + 2; + sections.push({ + kind: 'gradient', + title, + titleX: x, + titleY, + colors, + ticks: ticks(x, labels), + barY, + tickBottom: barY + LEGEND_BAR_HEIGHT + 3, + labelY: barY + LEGEND_BAR_HEIGHT + fontSize, + gradientId, + }); + y = barY + LEGEND_BAR_HEIGHT + fontSize * 2 + 6; + }; + + if (hasGCContent) { + addSection('GC Content', ['rgb(255, 55, 50)', 'rgb(55, 255, 50)'], ['0%', '50%', '100%'], 'gc-content-grad'); + } + if (hasGCSkew) { + addSection('GC Skew', ['#a855f7', '#22c55e'], ['-1', '0', '+1'], 'gc-skew-grad'); + } + + return { + x, + fontSize, + sections, + bounds: { x: x - 5, y: startY, width: LEGEND_BAR_WIDTH + 10, height: y - startY }, + }; +} + +export function buildRingLegendScene( + config: RenderConfig, + position: Point | null, + rings: readonly RingData[], +): LegendScene { + const x = position?.x ?? config.width - 200; + const startY = position?.y ?? 20; + const fontSize = config.legendFontSize; + const sections: LegendSectionScene[] = []; + let y = startY + fontSize; + + for (const ring of rings.filter(candidate => candidate.visible)) { + if (ring.hits.length === 0) { + sections.push({ + kind: 'swatch', + title: ring.queryName, + titleX: x + 16, + titleY: y, + swatch: { + x, + y: y - fontSize + 3, + width: 12, + height: fontSize, + fill: ring.color, + }, + }); + y += fontSize * 2 + 2; + continue; + } + + const upper = ring.upperThreshold ?? config.maxIdentity; + const lower = ring.lowerThreshold ?? config.minIdentity; + const { r, g, b } = hexToRGB(ring.color); + const titleY = y; + const barY = titleY + fontSize - LEGEND_BAR_HEIGHT + 4; + sections.push({ + kind: 'gradient', + title: ring.queryName, + titleX: x, + titleY, + colors: [ + `rgb(${Math.round(255 + (r - 255) * 0.15)}, ${Math.round(255 + (g - 255) * 0.15)}, ${Math.round(255 + (b - 255) * 0.15)})`, + ring.color, + ], + ticks: ticks(x, [`${lower}%`, `${Math.round((upper + lower) / 2)}%`, `${upper}%`]), + barY, + tickBottom: barY + LEGEND_BAR_HEIGHT + 1, + labelY: titleY + fontSize * 2 + 2, + gradientId: `grad-${ring.queryId}`, + }); + y += fontSize * 3 + LEGEND_BAR_HEIGHT + 6; + } + + return { + x, + fontSize, + sections, + bounds: { x: x - 5, y: startY, width: 200, height: y - startY + 5 }, + }; +} diff --git a/lib/rendering/svgLegends.ts b/lib/rendering/svgLegends.ts index 3636fa4..aed440e 100644 --- a/lib/rendering/svgLegends.ts +++ b/lib/rendering/svgLegends.ts @@ -1,9 +1,8 @@ -import type { RingData } from '../types'; -import { hexToRGB } from '../geometry'; -import type { Point, RenderConfig } from './types'; - -const BAR_WIDTH = 120; -const BAR_HEIGHT = 10; +import { + LEGEND_BAR_HEIGHT, + LEGEND_BAR_WIDTH, + type LegendScene, +} from './legendLayout'; function svgElement(name: K): SVGElementTagNameMap[K] { return document.createElementNS('http://www.w3.org/2000/svg', name); @@ -21,7 +20,7 @@ function appendText( text.setAttribute('x', String(x)); text.setAttribute('y', String(y)); text.setAttribute('font-size', String(fontSize)); - text.setAttribute('fill', '#333'); + text.setAttribute('fill', options.bold ? '#333' : '#666'); if (options.bold) text.setAttribute('font-weight', 'bold'); if (options.anchor) text.setAttribute('text-anchor', options.anchor); text.textContent = textContent; @@ -31,12 +30,11 @@ function appendText( function appendGradient( definitions: SVGDefsElement, id: string, - startColor: string, - endColor: string, + colors: readonly [string, string], ): void { const gradient = svgElement('linearGradient'); gradient.setAttribute('id', id); - for (const [offset, color] of [['0%', startColor], ['100%', endColor]]) { + for (const [offset, color] of [['0%', colors[0]], ['100%', colors[1]]]) { const stop = svgElement('stop'); stop.setAttribute('offset', offset); stop.setAttribute('stop-color', color); @@ -45,156 +43,112 @@ function appendGradient( definitions.appendChild(gradient); } -function appendBar(parent: SVGElement, x: number, y: number, fill: string): void { - const bar = svgElement('rect'); - bar.setAttribute('x', String(x)); - bar.setAttribute('y', String(y)); - bar.setAttribute('width', String(BAR_WIDTH)); - bar.setAttribute('height', String(BAR_HEIGHT)); - bar.setAttribute('fill', fill); - bar.setAttribute('rx', '2'); - bar.setAttribute('stroke', '#ccc'); - bar.setAttribute('stroke-width', '0.5'); - parent.appendChild(bar); -} - -function appendTicks( +function appendRoundedRect( parent: SVGElement, - ticks: ReadonlyArray<{ label: string; x: number }>, - y: number, - tickBottom: number, - labelY: number, - fontSize: number, -): void { - for (const tick of ticks) { - const line = svgElement('line'); - line.setAttribute('x1', String(tick.x)); - line.setAttribute('y1', String(y)); - line.setAttribute('x2', String(tick.x)); - line.setAttribute('y2', String(tickBottom)); - line.setAttribute('stroke', '#666'); - line.setAttribute('stroke-width', '1'); - parent.appendChild(line); - appendText(parent, tick.label, tick.x, labelY, fontSize - 3, { anchor: 'middle' }); - } -} - -function prependTransparentBounds( - group: SVGGElement, x: number, y: number, width: number, height: number, -): void { - const background = svgElement('rect'); - background.setAttribute('x', String(x)); - background.setAttribute('y', String(y)); - background.setAttribute('width', String(width)); - background.setAttribute('height', String(height)); - background.setAttribute('fill', '#fff'); - background.setAttribute('fill-opacity', '0'); - group.insertBefore(background, group.firstChild); + fill: string, + stroke = false, +): SVGRectElement { + const rect = svgElement('rect'); + rect.setAttribute('x', String(x)); + rect.setAttribute('y', String(y)); + rect.setAttribute('width', String(width)); + rect.setAttribute('height', String(height)); + rect.setAttribute('fill', fill); + rect.setAttribute('rx', '2'); + if (stroke) { + rect.setAttribute('stroke', '#ccc'); + rect.setAttribute('stroke-width', '0.5'); + } + parent.appendChild(rect); + return rect; } -export function renderSVGGCLegend( +export function renderSVGLegend( parent: SVGElement, definitions: SVGDefsElement, - config: RenderConfig, - position: Point | null, - hasGCContent: boolean, - hasGCSkew: boolean, + scene: LegendScene, + options: { id: string; label: string }, ): void { - const x = position?.x ?? 20; - const startY = position?.y ?? 20; - const fontSize = config.legendFontSize; const group = svgElement('g'); - group.setAttribute('id', 'gc-legend'); - group.setAttribute('inkscape:label', 'GC Legend'); - group.setAttribute('class', 'gc-legend'); - let y = startY + fontSize; + group.setAttribute('id', options.id); + group.setAttribute('inkscape:label', options.label); + group.setAttribute('class', options.id); - if (hasGCContent) { - appendText(group, 'GC Content', x, y, fontSize, { bold: true }); - y += fontSize + 2; - appendGradient(definitions, 'gc-content-grad', 'rgb(255, 55, 50)', 'rgb(55, 255, 50)'); - appendBar(group, x, y, 'url(#gc-content-grad)'); - appendTicks(group, [ - { label: '0%', x }, - { label: '50%', x: x + BAR_WIDTH / 2 }, - { label: '100%', x: x + BAR_WIDTH }, - ], y, y + BAR_HEIGHT + 3, y + BAR_HEIGHT + fontSize, fontSize); - y += BAR_HEIGHT + fontSize * 2 + 6; - } + const bounds = appendRoundedRect( + group, + scene.bounds.x, + scene.bounds.y, + scene.bounds.width, + scene.bounds.height, + '#fff', + ); + bounds.setAttribute('fill-opacity', '0'); - if (hasGCSkew) { - appendText(group, 'GC Skew', x, y, fontSize, { bold: true }); - y += fontSize + 2; - appendGradient(definitions, 'gc-skew-grad', '#a855f7', '#22c55e'); - appendBar(group, x, y, 'url(#gc-skew-grad)'); - appendTicks(group, [ - { label: '-1', x }, - { label: '0', x: x + BAR_WIDTH / 2 }, - { label: '+1', x: x + BAR_WIDTH }, - ], y, y + BAR_HEIGHT + 3, y + BAR_HEIGHT + fontSize, fontSize); - } - - prependTransparentBounds(group, x - 5, startY, BAR_WIDTH + 10, y + BAR_HEIGHT + fontSize + 5 - startY); - parent.appendChild(group); -} + for (const section of scene.sections) { + if (section.kind === 'swatch' && section.swatch) { + appendRoundedRect( + group, + section.swatch.x, + section.swatch.y, + section.swatch.width, + section.swatch.height, + section.swatch.fill, + ); + } -export function renderSVGRingLegend( - parent: SVGElement, - definitions: SVGDefsElement, - config: RenderConfig, - position: Point | null, - rings: readonly RingData[], -): void { - const x = position?.x ?? config.width - 200; - const startY = position?.y ?? 20; - const fontSize = config.legendFontSize; - const group = svgElement('g'); - group.setAttribute('id', 'ring-legend'); - group.setAttribute('inkscape:label', 'Ring Legend'); - group.setAttribute('class', 'ring-legend'); - let y = startY + fontSize; + appendText( + group, + section.title, + section.titleX, + section.titleY, + scene.fontSize, + { bold: true }, + ); - for (const ring of rings.filter(candidate => candidate.visible)) { - if (ring.hits.length === 0) { - const swatch = svgElement('rect'); - swatch.setAttribute('x', String(x)); - swatch.setAttribute('y', String(y - fontSize + 3)); - swatch.setAttribute('width', '12'); - swatch.setAttribute('height', String(fontSize)); - swatch.setAttribute('fill', ring.color); - swatch.setAttribute('rx', '2'); - group.appendChild(swatch); - appendText(group, ring.queryName, x + 16, y, fontSize, { bold: true }); - y += fontSize * 2 + 2; - continue; - } + if ( + section.kind !== 'gradient' + || !section.colors + || !section.gradientId + || section.barY === undefined + || !section.ticks + || section.tickBottom === undefined + || section.labelY === undefined + ) continue; - appendText(group, ring.queryName, x, y, fontSize, { bold: true }); - y += fontSize + 2; - const upper = ring.upperThreshold ?? config.maxIdentity; - const lower = ring.lowerThreshold ?? config.minIdentity; - const gradientId = `grad-${ring.queryId}`; - const { r, g, b } = hexToRGB(ring.color); - appendGradient( - definitions, - gradientId, - `rgb(${Math.round(255 + (r - 255) * 0.15)}, ${Math.round(255 + (g - 255) * 0.15)}, ${Math.round(255 + (b - 255) * 0.15)})`, - ring.color, + appendGradient(definitions, section.gradientId, section.colors); + appendRoundedRect( + group, + scene.x, + section.barY, + LEGEND_BAR_WIDTH, + LEGEND_BAR_HEIGHT, + `url(#${section.gradientId})`, + true, ); - const barY = y - BAR_HEIGHT + 2; - appendBar(group, x, barY, `url(#${gradientId})`); - appendTicks(group, [ - { label: `${lower}%`, x }, - { label: `${Math.round((upper + lower) / 2)}%`, x: x + BAR_WIDTH / 2 }, - { label: `${upper}%`, x: x + BAR_WIDTH }, - ], barY, y + 4, y + fontSize, fontSize); - y += fontSize * 2 + BAR_HEIGHT + 4; + + for (const tick of section.ticks) { + const line = svgElement('line'); + line.setAttribute('x1', String(tick.x)); + line.setAttribute('y1', String(section.barY)); + line.setAttribute('x2', String(tick.x)); + line.setAttribute('y2', String(section.tickBottom)); + line.setAttribute('stroke', '#666'); + line.setAttribute('stroke-width', '1'); + group.appendChild(line); + appendText( + group, + tick.label, + tick.x, + section.labelY, + scene.fontSize - 3, + { anchor: 'middle' }, + ); + } } - prependTransparentBounds(group, x - 5, startY, 200, y - startY + 5); parent.appendChild(group); } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 6881de9..430810f 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -18,6 +18,7 @@ import ProductFooter from '@/components/ProductFooter'; import ProductNav from '@/components/ProductNav'; import { useBRIGController } from '@/hooks/useBRIGController'; import { formatVisibleConsoleLine, isUsefulConsoleLine } from '@/hooks/useConsoleCapture'; +import { selectDisplayedPlotData } from '@/lib/plotScene'; import type { PlotViewState } from '@/lib/types'; const AnnotationEditor = lazy(() => import('@/components/AnnotationEditor')); @@ -62,14 +63,7 @@ export default function Home() { }, []); const displayedPlotData = useMemo(() => { if (!plotData) return null; - return { - ...plotData, - reference: { - ...plotData.reference, - gcContent: params.showGCContent !== false ? plotData.reference.gcContent : undefined, - gcSkew: params.showGCSkew !== false ? plotData.reference.gcSkew : undefined, - }, - }; + return selectDisplayedPlotData(plotData, params); }, [plotData, params.showGCContent, params.showGCSkew]); const visibleConsoleLogs = useMemo( () => consoleLogs.filter(isUsefulConsoleLine).map(formatVisibleConsoleLine), From 74ef79cfd64f3a02bbedb5fd51fb67d1b50d40e9 Mon Sep 17 00:00:00 2001 From: Nabil-Fareed Alikhan Date: Fri, 4 Sep 2026 08:36:41 +0100 Subject: [PATCH 2/2] Guard shared renderer boundaries --- lib/canvas-renderer.ts | 2 +- lib/renderer.ts | 2 +- scripts/check-architecture.sh | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/canvas-renderer.ts b/lib/canvas-renderer.ts index 4d75842..9899c38 100644 --- a/lib/canvas-renderer.ts +++ b/lib/canvas-renderer.ts @@ -242,7 +242,7 @@ export class CanvasPlotRenderer { const ringWidth = layout.width; const radius = layout.radius; - if (ring.graphPoints && ring.graphPoints.length > 0) { + if (layout.type === 'graph') { this.drawGraphRing(ctx, cx, cy, layout, track.graphArcs); } else { this.drawQueryRing(ctx, cx, cy, track.alignmentArcs); diff --git a/lib/renderer.ts b/lib/renderer.ts index b3a24f0..56577f4 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -424,7 +424,7 @@ export class CircularPlotRenderer { const radius = layout.radius; // Render as graph ring if it has graph data, otherwise as alignment ring - if (ring.graphPoints && ring.graphPoints.length > 0) { + if (layout.type === 'graph') { this.renderGraphRing(mainGroup, cx, cy, track); } else { this.renderQueryRing(mainGroup, cx, cy, track); diff --git a/scripts/check-architecture.sh b/scripts/check-architecture.sh index 191e6e2..e3046c5 100644 --- a/scripts/check-architecture.sh +++ b/scripts/check-architecture.sh @@ -22,4 +22,21 @@ if rg -n "from[[:space:]]+[\"'][^\"']*renderer[\"']" lib/canvas-renderer.ts; the failed=1 fi +for renderer in lib/canvas-renderer.ts lib/renderer.ts; do + if ! rg -q "from './plotScene'" "$renderer"; then + echo "Architecture check: $renderer must consume the shared plot scene." + failed=1 + fi +done + +if rg -n "(calculateRingLayout|positionToAngle|getColorIntensity|hexToRGB)" lib/canvas-renderer.ts lib/renderer.ts; then + echo "Architecture check: renderer backends must not recalculate shared plot geometry or colours." + failed=1 +fi + +if rg -n "data\.(reference|rings)" lib/canvas-renderer.ts lib/renderer.ts; then + echo "Architecture check: renderer backends must use scene visibility, not raw plot data." + failed=1 +fi + exit "$failed"