From 55bd5b6c43222f36974442eb05103414f0727ee8 Mon Sep 17 00:00:00 2001 From: Tobias O Date: Fri, 20 Mar 2026 14:40:31 +0100 Subject: [PATCH 01/49] feat(utils): add publication-export module and canvas limits - Add mm-based layout, typography, legend draw, scatter capture, composer - Add buildPublicationLegendModel for selection-aware counts - Export canvas-limits for shared max dimension/area checks - Extend PersistedExportOptions with publication preset and legend placement - Validate optional publication fields in bundle settings Made-with: Cursor --- packages/utils/src/index.ts | 2 + .../src/parquet/settings-validation.test.ts | 19 +++ .../utils/src/parquet/settings-validation.ts | 29 +++- packages/utils/src/types.ts | 6 + .../utils/src/visualization/canvas-limits.ts | 28 ++++ .../build-legend-model.test.ts | 113 +++++++++++++++ .../publication-export/build-legend-model.ts | 86 +++++++++++ .../publication-export/export-publication.ts | 57 ++++++++ .../publication-export/figure-composer.ts | 49 +++++++ .../visualization/publication-export/index.ts | 29 ++++ .../publication-export/layout.test.ts | 55 ++++++++ .../publication-export/layout.ts | 66 +++++++++ .../publication-export/legend-canvas.ts | 133 ++++++++++++++++++ .../legend-caps.CALIBRATION.md | 8 ++ .../publication-export/legend-caps.ts | 14 ++ .../publication-export/legend-model.ts | 30 ++++ .../publication-export/legend-slice.test.ts | 23 +++ .../publication-export/legend-slice.ts | 22 +++ .../legend-text-layout.test.ts | 32 +++++ .../publication-export/legend-text-layout.ts | 60 ++++++++ .../publication-export/pdf-output.ts | 19 +++ .../publication-export/pixel-rect.ts | 22 +++ .../publication-export/png-output.ts | 7 + .../publication-export/presets.ts | 38 +++++ .../publication-export/scatter-capture.ts | 58 ++++++++ .../publication-export/typography.test.ts | 29 ++++ .../publication-export/typography.ts | 20 +++ 27 files changed, 1053 insertions(+), 1 deletion(-) create mode 100644 packages/utils/src/visualization/canvas-limits.ts create mode 100644 packages/utils/src/visualization/publication-export/build-legend-model.test.ts create mode 100644 packages/utils/src/visualization/publication-export/build-legend-model.ts create mode 100644 packages/utils/src/visualization/publication-export/export-publication.ts create mode 100644 packages/utils/src/visualization/publication-export/figure-composer.ts create mode 100644 packages/utils/src/visualization/publication-export/index.ts create mode 100644 packages/utils/src/visualization/publication-export/layout.test.ts create mode 100644 packages/utils/src/visualization/publication-export/layout.ts create mode 100644 packages/utils/src/visualization/publication-export/legend-canvas.ts create mode 100644 packages/utils/src/visualization/publication-export/legend-caps.CALIBRATION.md create mode 100644 packages/utils/src/visualization/publication-export/legend-caps.ts create mode 100644 packages/utils/src/visualization/publication-export/legend-model.ts create mode 100644 packages/utils/src/visualization/publication-export/legend-slice.test.ts create mode 100644 packages/utils/src/visualization/publication-export/legend-slice.ts create mode 100644 packages/utils/src/visualization/publication-export/legend-text-layout.test.ts create mode 100644 packages/utils/src/visualization/publication-export/legend-text-layout.ts create mode 100644 packages/utils/src/visualization/publication-export/pdf-output.ts create mode 100644 packages/utils/src/visualization/publication-export/pixel-rect.ts create mode 100644 packages/utils/src/visualization/publication-export/png-output.ts create mode 100644 packages/utils/src/visualization/publication-export/presets.ts create mode 100644 packages/utils/src/visualization/publication-export/scatter-capture.ts create mode 100644 packages/utils/src/visualization/publication-export/typography.test.ts create mode 100644 packages/utils/src/visualization/publication-export/typography.ts diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 7d8ebb5b..c8f27661 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -4,6 +4,8 @@ export * from './visualization/data-processor'; export * from './visualization/color-scheme'; export * from './visualization/scales'; export * from './visualization/export-utils'; +export * from './visualization/canvas-limits'; +export * from './visualization/publication-export'; export * from './visualization/notification-utils'; export * from './structure/structure-service'; export * from './message'; diff --git a/packages/utils/src/parquet/settings-validation.test.ts b/packages/utils/src/parquet/settings-validation.test.ts index 2cfb61cd..f574a905 100644 --- a/packages/utils/src/parquet/settings-validation.test.ts +++ b/packages/utils/src/parquet/settings-validation.test.ts @@ -37,6 +37,8 @@ const createValidExportOptions = (): PersistedExportOptions => ({ legendFontSizePx: 24, includeLegendSettings: true, includeExportOptions: true, + publicationPresetId: 'two_column', + legendPlacement: 'right', }); const createNormalizedBundleSettings = (): BundleSettings => ({ @@ -119,6 +121,23 @@ describe('settings-validation', () => { ).toBe(false); expect(isValidPersistedExportOptions(null)).toBe(false); }); + + it('accepts legacy export options without publication fields', () => { + const { + publicationPresetId: _p, + legendPlacement: _l, + ...legacy + } = createValidExportOptions(); + expect(isValidPersistedExportOptions(legacy)).toBe(true); + }); + + it('rejects invalid publication preset id', () => { + const bad: Record = { + ...createValidExportOptions(), + publicationPresetId: 'wide', + }; + expect(isValidPersistedExportOptions(bad)).toBe(false); + }); }); describe('bundle settings formats', () => { diff --git a/packages/utils/src/parquet/settings-validation.ts b/packages/utils/src/parquet/settings-validation.ts index f005185b..34340021 100644 --- a/packages/utils/src/parquet/settings-validation.ts +++ b/packages/utils/src/parquet/settings-validation.ts @@ -7,6 +7,8 @@ import type { PersistedCategoryData, PersistedExportOptions, LegendSortMode, + PublicationFigurePresetId, + PublicationLegendPlacementId, } from '../types'; /** @@ -78,6 +80,29 @@ export function isValidLegendSettings(obj: unknown): obj is LegendPersistedSetti return true; } +const PUBLICATION_PRESET_IDS: PublicationFigurePresetId[] = [ + 'one_column', + 'two_column', + 'full_page', +]; + +const PUBLICATION_LEGEND_PLACEMENTS: PublicationLegendPlacementId[] = ['right', 'below']; + +function isOptionalPublicationPresetId(value: unknown): boolean { + if (value === undefined) return true; + return ( + typeof value === 'string' && PUBLICATION_PRESET_IDS.includes(value as PublicationFigurePresetId) + ); +} + +function isOptionalPublicationLegendPlacement(value: unknown): boolean { + if (value === undefined) return true; + return ( + typeof value === 'string' && + PUBLICATION_LEGEND_PLACEMENTS.includes(value as PublicationLegendPlacementId) + ); +} + /** * Validates that a value is a valid PersistedExportOptions object. */ @@ -92,7 +117,9 @@ export function isValidPersistedExportOptions(obj: unknown): obj is PersistedExp typeof settings.legendWidthPercent === 'number' && typeof settings.legendFontSizePx === 'number' && typeof settings.includeLegendSettings === 'boolean' && - typeof settings.includeExportOptions === 'boolean' + typeof settings.includeExportOptions === 'boolean' && + isOptionalPublicationPresetId(settings.publicationPresetId) && + isOptionalPublicationLegendPlacement(settings.legendPlacement) ); } diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index c9df3071..3aefe6db 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -87,8 +87,12 @@ export interface LegendPersistedSettings { selectedPaletteId: string; } +export type PublicationFigurePresetId = 'one_column' | 'two_column' | 'full_page'; +export type PublicationLegendPlacementId = 'right' | 'below'; + /** * Export settings persisted per dataset + annotation. + * Legacy pixel/slider fields are retained for bundle import compatibility; the UI uses publication fields for PNG/PDF. */ export interface PersistedExportOptions { imageWidth: number; @@ -98,6 +102,8 @@ export interface PersistedExportOptions { legendFontSizePx: number; includeLegendSettings: boolean; includeExportOptions: boolean; + publicationPresetId?: PublicationFigurePresetId; + legendPlacement?: PublicationLegendPlacementId; } export type LegendSettingsMap = Record; diff --git a/packages/utils/src/visualization/canvas-limits.ts b/packages/utils/src/visualization/canvas-limits.ts new file mode 100644 index 00000000..ed8f7f5e --- /dev/null +++ b/packages/utils/src/visualization/canvas-limits.ts @@ -0,0 +1,28 @@ +export const MAX_CANVAS_DIMENSION = 8192; +export const MAX_CANVAS_AREA = 268435456; +export const SAFE_DIMENSION_MARGIN = 0.95; + +export function validateCanvasDimensions( + targetWidth: number, + targetHeight: number, +): { isValid: boolean; reason?: string } { + const effectiveMaxDimension = Math.floor(MAX_CANVAS_DIMENSION * SAFE_DIMENSION_MARGIN); + + if (targetWidth > effectiveMaxDimension || targetHeight > effectiveMaxDimension) { + return { + isValid: false, + reason: `Dimensions exceed browser limit of ${MAX_CANVAS_DIMENSION}px per side. Maximum safe dimension: ${effectiveMaxDimension}px`, + }; + } + + const totalPixels = targetWidth * targetHeight; + const maxSafeArea = MAX_CANVAS_AREA * SAFE_DIMENSION_MARGIN; + if (totalPixels > maxSafeArea) { + return { + isValid: false, + reason: `Total pixel area (${totalPixels.toLocaleString()}) exceeds browser limit (~${Math.floor(maxSafeArea).toLocaleString()} pixels)`, + }; + } + + return { isValid: true }; +} diff --git a/packages/utils/src/visualization/publication-export/build-legend-model.test.ts b/packages/utils/src/visualization/publication-export/build-legend-model.test.ts new file mode 100644 index 00000000..f2f34b93 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/build-legend-model.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { buildPublicationLegendModel } from './build-legend-model'; +import { LEGEND_VALUES } from '../shapes'; +import type { ExportableData } from '../export-utils'; + +const data: ExportableData = { + protein_ids: ['P1', 'P2', 'P3'], + annotations: { + species: { + values: ['human', null], + colors: ['#f00', '#888'], + shapes: ['circle', 'square'], + }, + }, + annotation_data: { species: [[0], [1], [1]] }, +}; + +describe('buildPublicationLegendModel', () => { + it('keeps legend counts when no selection', () => { + const model = buildPublicationLegendModel({ + legendExport: { + annotation: 'species', + includeShapes: true, + otherItemsCount: 0, + items: [ + { + value: 'human', + color: '#f00', + shape: 'circle', + count: 1, + isVisible: true, + zOrder: 0, + }, + { + value: LEGEND_VALUES.NA_VALUE, + color: '#888', + shape: 'square', + count: 2, + isVisible: true, + zOrder: 1, + }, + ], + }, + data, + annotationKey: 'species', + selectedProteinIds: [], + hiddenAnnotationValues: [], + }); + const na = model.items.find((i) => i.value === LEGEND_VALUES.NA_VALUE); + expect(na?.count).toBe(2); + }); + + it('recomputes counts for selection', () => { + const model = buildPublicationLegendModel({ + legendExport: { + annotation: 'species', + includeShapes: true, + otherItemsCount: 0, + items: [ + { + value: 'human', + color: '#f00', + shape: 'circle', + count: 99, + isVisible: true, + zOrder: 0, + }, + { + value: LEGEND_VALUES.NA_VALUE, + color: '#888', + shape: 'square', + count: 99, + isVisible: true, + zOrder: 1, + }, + ], + }, + data, + annotationKey: 'species', + selectedProteinIds: ['P2'], + hiddenAnnotationValues: [], + }); + const na = model.items.find((i) => i.value === LEGEND_VALUES.NA_VALUE); + expect(na?.count).toBe(1); + const human = model.items.find((i) => i.value === 'human'); + expect(human?.count).toBe(0); + }); + + it('drops hidden annotation values', () => { + const model = buildPublicationLegendModel({ + legendExport: { + annotation: 'species', + includeShapes: true, + otherItemsCount: 0, + items: [ + { + value: 'human', + color: '#f00', + shape: 'circle', + count: 1, + isVisible: true, + zOrder: 0, + }, + ], + }, + data, + annotationKey: 'species', + selectedProteinIds: [], + hiddenAnnotationValues: ['human'], + }); + expect(model.items.some((i) => i.value === 'human')).toBe(false); + }); +}); diff --git a/packages/utils/src/visualization/publication-export/build-legend-model.ts b/packages/utils/src/visualization/publication-export/build-legend-model.ts new file mode 100644 index 00000000..7c097370 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/build-legend-model.ts @@ -0,0 +1,86 @@ +import type { ExportableData } from '../export-utils'; +import { LEGEND_VALUES, toDisplayValue } from '../shapes'; +import type { + LegendExportSnapshot, + PublicationLegendModel, + PublicationLegendRow, +} from './legend-model'; + +function proteinIndicesFromIds(data: ExportableData, ids: readonly string[]): Set { + const set = new Set(); + const m = new Map(); + for (let i = 0; i < data.protein_ids.length; i += 1) { + m.set(data.protein_ids[i], i); + } + for (const id of ids) { + const idx = m.get(id); + if (idx !== undefined) set.add(idx); + } + return set; +} + +function countsByValueKey( + data: ExportableData, + annotation: string, + allowedProteinIndices: Set | null, +): Map { + const annotationInfo = data.annotations[annotation]; + const indices = data.annotation_data[annotation]; + const counts = new Map(); + if (!annotationInfo || !indices || !Array.isArray(annotationInfo.values)) { + return counts; + } + + for (let i = 0; i < indices.length; i += 1) { + if (allowedProteinIndices && !allowedProteinIndices.has(i)) continue; + const viArray = indices[i]; + if (!Array.isArray(viArray)) continue; + for (const vi of viArray) { + if (typeof vi !== 'number' || vi < 0 || vi >= annotationInfo.values.length) continue; + const raw = annotationInfo.values[vi]; + const key = raw === null ? LEGEND_VALUES.NA_VALUE : String(raw); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + return counts; +} + +export function buildPublicationLegendModel(input: { + legendExport: LegendExportSnapshot; + data: ExportableData | null; + annotationKey: string; + selectedProteinIds: readonly string[]; + hiddenAnnotationValues: readonly string[]; +}): PublicationLegendModel { + const hiddenSet = new Set(input.hiddenAnnotationValues); + const ordered = [...input.legendExport.items] + .filter((it) => it.isVisible && !hiddenSet.has(it.value)) + .sort((a, b) => a.zOrder - b.zOrder); + + const selectionActive = input.selectedProteinIds.length > 0; + let countMap: Map | null = null; + if (selectionActive && input.data) { + const allowed = proteinIndicesFromIds(input.data, input.selectedProteinIds); + countMap = countsByValueKey(input.data, input.annotationKey, allowed); + } + + const items: PublicationLegendRow[] = ordered.map((it) => ({ + value: it.value, + displayLabel: toDisplayValue( + it.value, + it.value === LEGEND_VALUES.OTHER ? input.legendExport.otherItemsCount : undefined, + ), + color: it.color, + shape: it.shape, + count: countMap !== null ? (countMap.get(it.value) ?? 0) : it.count, + isVisible: it.isVisible, + zOrder: it.zOrder, + })); + + return { + annotationTitle: input.legendExport.annotation, + includeShapes: input.legendExport.includeShapes, + otherItemsCount: input.legendExport.otherItemsCount, + items, + }; +} diff --git a/packages/utils/src/visualization/publication-export/export-publication.ts b/packages/utils/src/visualization/publication-export/export-publication.ts new file mode 100644 index 00000000..89369a7b --- /dev/null +++ b/packages/utils/src/visualization/publication-export/export-publication.ts @@ -0,0 +1,57 @@ +import { validateCanvasDimensions } from '../canvas-limits'; +import { FIGURE_PRESETS, type FigurePresetId, type LegendPlacement } from './presets'; +import { computePublicationLayout } from './layout'; +import { PRINT_DPI_DEFAULT, mmToPx } from './typography'; +import { captureScatterForLayout, type ScatterplotCaptureFn } from './scatter-capture'; +import { composePublicationFigureRaster } from './figure-composer'; +import { drawPublicationLegend } from './legend-canvas'; +import type { PublicationLegendModel } from './legend-model'; +import { downloadPng } from './png-output'; +import { downloadPublicationPdf } from './pdf-output'; + +export interface PublicationExportRequest { + presetId: FigurePresetId; + legendPlacement: LegendPlacement; + format: 'png' | 'pdf'; + dpi?: number; + backgroundColor?: string; + scatterCapture: ScatterplotCaptureFn; + legendModel: PublicationLegendModel; + fileNameBase?: string; +} + +export async function exportPublicationFigure(req: PublicationExportRequest): Promise { + const preset = FIGURE_PRESETS[req.presetId]; + const dpi = req.dpi ?? PRINT_DPI_DEFAULT; + const layout = computePublicationLayout(preset, req.legendPlacement); + const bg = req.backgroundColor ?? '#ffffff'; + + const figW = Math.round(mmToPx(layout.figureMm.width, dpi)); + const figH = Math.round(mmToPx(layout.figureMm.height, dpi)); + const figVal = validateCanvasDimensions(figW, figH); + if (!figVal.isValid) { + throw new Error(figVal.reason ?? 'Figure dimensions exceed browser limits'); + } + + const scatterCanvas = captureScatterForLayout(layout.scatterMm, dpi, req.scatterCapture, bg); + + const finalCanvas = composePublicationFigureRaster({ + layout, + scatterCanvas, + legendDrawer: (ctx, rect) => + drawPublicationLegend(ctx, rect, req.legendModel, { + dpi, + presetId: req.presetId, + legendPlacement: req.legendPlacement, + }), + dpi, + backgroundColor: bg, + }); + + const name = req.fileNameBase ?? `protspace_${req.presetId}_${req.legendPlacement}`; + if (req.format === 'png') { + downloadPng(finalCanvas, `${name}.png`); + } else { + await downloadPublicationPdf(finalCanvas, layout, `${name}.pdf`); + } +} diff --git a/packages/utils/src/visualization/publication-export/figure-composer.ts b/packages/utils/src/visualization/publication-export/figure-composer.ts new file mode 100644 index 00000000..dccc224e --- /dev/null +++ b/packages/utils/src/visualization/publication-export/figure-composer.ts @@ -0,0 +1,49 @@ +import { validateCanvasDimensions } from '../canvas-limits'; +import type { PublicationLayout } from './layout'; +import type { PxRect } from './pixel-rect'; +import { mmRectToPx, rectToDrawImageArgs } from './pixel-rect'; +import { mmToPx } from './typography'; + +export function composePublicationFigureRaster(options: { + layout: PublicationLayout; + scatterCanvas: HTMLCanvasElement; + legendDrawer: (ctx: CanvasRenderingContext2D, rect: PxRect) => void; + dpi: number; + backgroundColor: string; +}): HTMLCanvasElement { + const { layout, scatterCanvas, legendDrawer, dpi, backgroundColor } = options; + const W = Math.round(mmToPx(layout.figureMm.width, dpi)); + const H = Math.round(mmToPx(layout.figureMm.height, dpi)); + const figCheck = validateCanvasDimensions(W, H); + if (!figCheck.isValid) { + throw new Error(figCheck.reason ?? 'Figure dimensions exceed browser limits'); + } + + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Could not create 2D canvas context for publication export'); + } + + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, W, H); + + const scatterPx = mmRectToPx(layout.scatterMm, dpi); + const legendPx = mmRectToPx(layout.legendMm, dpi); + const [dx, dy, dw, dh] = rectToDrawImageArgs(scatterPx); + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(scatterCanvas, 0, 0, scatterCanvas.width, scatterCanvas.height, dx, dy, dw, dh); + + ctx.save(); + ctx.beginPath(); + ctx.rect(legendPx.x, legendPx.y, legendPx.width, legendPx.height); + ctx.clip(); + legendDrawer(ctx, legendPx); + ctx.restore(); + + return canvas; +} diff --git a/packages/utils/src/visualization/publication-export/index.ts b/packages/utils/src/visualization/publication-export/index.ts new file mode 100644 index 00000000..d74b4a25 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/index.ts @@ -0,0 +1,29 @@ +export type { FigurePresetId, LegendPlacement, FigurePreset } from './presets'; +export { FIGURE_PRESETS } from './presets'; +export { MAX_LEGEND_ITEMS, maxLegendItemsForLayout } from './legend-caps'; +export { sliceLegendItemsForLayout, type SlicedLegend } from './legend-slice'; +export type { MmRect, PublicationLayout } from './layout'; +export { computePublicationLayout } from './layout'; +export { PRINT_DPI_DEFAULT, ptToPx, mmToPx, legendBodyPt } from './typography'; +export { wrapLabelToTwoLines, type WrappedLabel } from './legend-text-layout'; +export type { + PublicationLegendModel, + PublicationLegendRow, + LegendExportSnapshot, +} from './legend-model'; +export { buildPublicationLegendModel } from './build-legend-model'; +export { + scatterTargetPixels, + captureScatterForLayout, + createScatterCaptureFromElement, + type ScatterplotCaptureFn, + type ScatterplotCaptureElement, + type ScatterCaptureOptions, +} from './scatter-capture'; +export type { PxRect } from './pixel-rect'; +export { mmRectToPx, rectToDrawImageArgs } from './pixel-rect'; +export { composePublicationFigureRaster } from './figure-composer'; +export { drawPublicationLegend, EXPORT_FONT_FAMILY } from './legend-canvas'; +export { downloadPng } from './png-output'; +export { downloadPublicationPdf } from './pdf-output'; +export { exportPublicationFigure, type PublicationExportRequest } from './export-publication'; diff --git a/packages/utils/src/visualization/publication-export/layout.test.ts b/packages/utils/src/visualization/publication-export/layout.test.ts new file mode 100644 index 00000000..f9663ad9 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/layout.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { computePublicationLayout } from './layout'; +import { FIGURE_PRESETS, type FigurePresetId, type LegendPlacement } from './presets'; + +function assertLayoutInvariants(presetId: FigurePresetId, placement: LegendPlacement): void { + const preset = FIGURE_PRESETS[presetId]; + const layout = computePublicationLayout(preset, placement); + const pad = preset.paddingMm; + const innerW = preset.widthMm - 2 * pad; + const innerH = preset.heightMm - 2 * pad; + + expect(layout.figureMm.width).toBe(preset.widthMm); + expect(layout.figureMm.height).toBe(preset.heightMm); + + const { scatterMm, legendMm } = layout; + expect(scatterMm.x).toBeGreaterThanOrEqual(pad - 1e-6); + expect(scatterMm.y).toBeGreaterThanOrEqual(pad - 1e-6); + expect(scatterMm.width).toBeGreaterThan(0); + expect(scatterMm.height).toBeGreaterThan(0); + expect(scatterMm.width / scatterMm.height).toBeCloseTo(preset.scatterAspect, 5); + + if (placement === 'right') { + expect(legendMm.width).toBe(preset.legendBandMm.right); + expect(legendMm.height).toBeCloseTo(innerH, 5); + expect(legendMm.x).toBeCloseTo(pad + innerW - legendMm.width, 5); + expect(scatterMm.x + scatterMm.width).toBeLessThanOrEqual(legendMm.x + 1e-6); + expect(legendMm.x + legendMm.width).toBeCloseTo(pad + innerW, 5); + } else { + expect(legendMm.height).toBe(preset.legendBandMm.below); + expect(legendMm.width).toBeCloseTo(innerW, 5); + expect(scatterMm.y + scatterMm.height).toBeCloseTo(legendMm.y, 5); + expect(legendMm.y + legendMm.height).toBeCloseTo(pad + innerH, 5); + } +} + +describe('computePublicationLayout', () => { + const presets: FigurePresetId[] = ['one_column', 'two_column', 'full_page']; + const placements: LegendPlacement[] = ['right', 'below']; + + it.each(presets.flatMap((p) => placements.map((pl) => [p, pl] as const)))( + 'invariants for %s / %s', + (presetId, placement) => { + assertLayoutInvariants(presetId, placement); + }, + ); + + it('matches frozen snapshot for two_column / right', () => { + const layout = computePublicationLayout(FIGURE_PRESETS.two_column, 'right'); + expect(layout).toEqual({ + figureMm: { width: 178, height: 95 }, + scatterMm: { x: 7, y: 2.5, width: 120, height: 90 }, + legendMm: { x: 131.5, y: 2.5, width: 44, height: 90 }, + }); + }); +}); diff --git a/packages/utils/src/visualization/publication-export/layout.ts b/packages/utils/src/visualization/publication-export/layout.ts new file mode 100644 index 00000000..1ecf0a14 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/layout.ts @@ -0,0 +1,66 @@ +import type { FigurePreset, LegendPlacement } from './presets'; + +export interface MmRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface PublicationLayout { + figureMm: { width: number; height: number }; + scatterMm: MmRect; + legendMm: MmRect; +} + +export function computePublicationLayout( + preset: FigurePreset, + placement: LegendPlacement, +): PublicationLayout { + const { widthMm, heightMm, paddingMm, legendBandMm, scatterAspect } = preset; + const innerX = paddingMm; + const innerY = paddingMm; + const innerW = widthMm - 2 * paddingMm; + const innerH = heightMm - 2 * paddingMm; + + let scatterMm: MmRect; + let legendMm: MmRect; + + if (placement === 'right') { + const legendW = legendBandMm.right; + const availScatterW = innerW - legendW; + const hFromW = availScatterW / scatterAspect; + const scatterH = Math.min(hFromW, innerH); + const scatterW = scatterH * scatterAspect; + const scatterX = innerX + (availScatterW - scatterW) / 2; + const scatterY = innerY + (innerH - scatterH) / 2; + scatterMm = { x: scatterX, y: scatterY, width: scatterW, height: scatterH }; + legendMm = { + x: innerX + availScatterW, + y: innerY, + width: legendW, + height: innerH, + }; + } else { + const legendH = legendBandMm.below; + const availScatterH = innerH - legendH; + const wFromH = availScatterH * scatterAspect; + const scatterW = Math.min(wFromH, innerW); + const scatterH = scatterW / scatterAspect; + const scatterX = innerX + (innerW - scatterW) / 2; + const scatterY = innerY + (availScatterH - scatterH) / 2; + scatterMm = { x: scatterX, y: scatterY, width: scatterW, height: scatterH }; + legendMm = { + x: innerX, + y: innerY + availScatterH, + width: innerW, + height: legendH, + }; + } + + return { + figureMm: { width: widthMm, height: heightMm }, + scatterMm, + legendMm, + }; +} diff --git a/packages/utils/src/visualization/publication-export/legend-canvas.ts b/packages/utils/src/visualization/publication-export/legend-canvas.ts new file mode 100644 index 00000000..e91dd0ef --- /dev/null +++ b/packages/utils/src/visualization/publication-export/legend-canvas.ts @@ -0,0 +1,133 @@ +import { SHAPE_PATH_GENERATORS, renderPathOnCanvas } from '../shapes'; +import type { FigurePresetId, LegendPlacement } from './presets'; +import { sliceLegendItemsForLayout } from './legend-slice'; +import type { PublicationLegendModel } from './legend-model'; +import { wrapLabelToTwoLines } from './legend-text-layout'; +import { legendBodyPt, mmToPx, ptToPx } from './typography'; +import type { PxRect } from './pixel-rect'; + +export const EXPORT_FONT_FAMILY = + '"Roboto Condensed", "Arial Narrow", "Helvetica Neue", Arial, sans-serif'; + +const HEADER_BODY_MM = 5; + +function drawSymbol( + ctx: CanvasRenderingContext2D, + shape: string, + color: string, + cx: number, + cy: number, + size: number, + includeShapes: boolean, +): void { + if (includeShapes) { + const shapeKey = (shape || 'circle').toLowerCase(); + const pathGenerator = SHAPE_PATH_GENERATORS[shapeKey] ?? SHAPE_PATH_GENERATORS.circle; + const pathString = pathGenerator(size); + renderPathOnCanvas(ctx, pathString, cx, cy, color || '#888', '#394150', 1); + return; + } + ctx.save(); + ctx.fillStyle = color || '#888'; + ctx.strokeStyle = '#333'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(cx, cy, size / 2, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.restore(); +} + +export function drawPublicationLegend( + ctx: CanvasRenderingContext2D, + rect: PxRect, + model: PublicationLegendModel, + options: { + dpi: number; + presetId: FigurePresetId; + legendPlacement: LegendPlacement; + }, +): void { + const { dpi, presetId, legendPlacement } = options; + const paddingPx = Math.max(4, Math.round(mmToPx(0.6, dpi))); + const legendBoxMmH = (rect.height * 25.4) / dpi; + const padMm = (paddingPx * 25.4) / dpi; + const innerHmm = Math.max(4, legendBoxMmH - 2 * padMm - HEADER_BODY_MM); + + const { visible, omittedCount } = sliceLegendItemsForLayout( + model.items, + presetId, + legendPlacement, + ); + const displayedRows = visible.length + (omittedCount > 0 ? 1 : 0); + const bodyPt = legendBodyPt(innerHmm, displayedRows, HEADER_BODY_MM); + const headerPt = Math.min(10, bodyPt + 1); + + const bodyPx = ptToPx(bodyPt, dpi); + const headerPx = ptToPx(headerPt, dpi); + const lineHeight = bodyPx * 1.15; + const symbolSize = bodyPx * 1.15; + const rowGap = Math.max(2, bodyPx * 0.2); + const labelColumnGap = bodyPx * 0.45; + + ctx.save(); + ctx.fillStyle = '#1f2937'; + ctx.font = `600 ${headerPx}px ${EXPORT_FONT_FAMILY}`; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + const titleY = rect.y + paddingPx + headerPx / 2; + ctx.fillText(model.annotationTitle, rect.x + paddingPx, titleY); + + const countProbe = '0000000'; + ctx.font = `500 ${bodyPx}px ${EXPORT_FONT_FAMILY}`; + const countColW = ctx.measureText(countProbe).width + bodyPx * 0.35; + const labelMaxWidth = rect.width - paddingPx * 2 - symbolSize - labelColumnGap - countColW; + + let y = rect.y + paddingPx + headerPx + rowGap; + + for (const item of visible) { + const rowHeight = lineHeight * 2 + rowGap; + + const cx = rect.x + paddingPx + symbolSize / 2; + const cy = y + rowHeight / 2; + drawSymbol(ctx, item.shape, item.color, cx, cy, symbolSize, model.includeShapes); + + ctx.font = `500 ${bodyPx}px ${EXPORT_FONT_FAMILY}`; + ctx.fillStyle = '#1f2937'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + + const labelX = rect.x + paddingPx + symbolSize + labelColumnGap; + const wrapped = wrapLabelToTwoLines(ctx, item.displayLabel, labelMaxWidth); + + ctx.save(); + ctx.translate( + labelX, + y + (rowHeight - (wrapped.lines.length === 1 ? lineHeight : lineHeight * 2)) / 2, + ); + ctx.scale(0.9, 1); + for (let li = 0; li < wrapped.lines.length; li += 1) { + ctx.fillText(wrapped.lines[li], 0, li * lineHeight); + } + ctx.restore(); + + ctx.fillStyle = '#4b5563'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(item.count), rect.x + rect.width - paddingPx, cy); + + y += rowHeight; + } + + if (omittedCount > 0) { + const summary = `+ ${omittedCount.toLocaleString()} more categories`; + ctx.font = `500 ${bodyPx}px ${EXPORT_FONT_FAMILY}`; + ctx.fillStyle = '#374151'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + const wrappedSum = wrapLabelToTwoLines(ctx, summary, rect.width - paddingPx * 2); + ctx.fillText(wrappedSum.lines[0], rect.x + paddingPx, y); + } + + ctx.restore(); +} diff --git a/packages/utils/src/visualization/publication-export/legend-caps.CALIBRATION.md b/packages/utils/src/visualization/publication-export/legend-caps.CALIBRATION.md new file mode 100644 index 00000000..bd77aeeb --- /dev/null +++ b/packages/utils/src/visualization/publication-export/legend-caps.CALIBRATION.md @@ -0,0 +1,8 @@ +# Legend row caps (`MAX_LEGEND_ITEMS`) + +Placeholder caps until binary-search calibration (see implementation plan §3.2). + +- **DPI reference:** 300 +- **Method:** synthetic two-line labels at minimum body pt (7), measure vertical overflow per `(preset, placement)`. + +Update `legend-caps.ts` after each calibration run and note the date here. diff --git a/packages/utils/src/visualization/publication-export/legend-caps.ts b/packages/utils/src/visualization/publication-export/legend-caps.ts new file mode 100644 index 00000000..7fd163e0 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/legend-caps.ts @@ -0,0 +1,14 @@ +import type { FigurePresetId, LegendPlacement } from './presets'; + +export const MAX_LEGEND_ITEMS: Record> = { + one_column: { right: 10, below: 6 }, + two_column: { right: 18, below: 10 }, + full_page: { right: 28, below: 16 }, +}; + +export function maxLegendItemsForLayout( + presetId: FigurePresetId, + placement: LegendPlacement, +): number { + return MAX_LEGEND_ITEMS[presetId][placement]; +} diff --git a/packages/utils/src/visualization/publication-export/legend-model.ts b/packages/utils/src/visualization/publication-export/legend-model.ts new file mode 100644 index 00000000..6986cf52 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/legend-model.ts @@ -0,0 +1,30 @@ +export interface PublicationLegendRow { + value: string; + displayLabel: string; + color: string; + shape: string; + count: number; + isVisible: boolean; + zOrder: number; +} + +export interface PublicationLegendModel { + annotationTitle: string; + includeShapes: boolean; + otherItemsCount: number; + items: PublicationLegendRow[]; +} + +export interface LegendExportSnapshot { + annotation: string; + includeShapes: boolean; + otherItemsCount: number; + items: readonly { + value: string; + color: string; + shape: string; + count: number; + isVisible: boolean; + zOrder: number; + }[]; +} diff --git a/packages/utils/src/visualization/publication-export/legend-slice.test.ts b/packages/utils/src/visualization/publication-export/legend-slice.test.ts new file mode 100644 index 00000000..cda910b9 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/legend-slice.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { sliceLegendItemsForLayout } from './legend-slice'; +import { maxLegendItemsForLayout } from './legend-caps'; +import type { FigurePresetId, LegendPlacement } from './presets'; + +describe('sliceLegendItemsForLayout', () => { + it('returns all items when under cap', () => { + const items = ['a', 'b', 'c']; + const r = sliceLegendItemsForLayout(items, 'one_column', 'right'); + expect(r.visible).toEqual(items); + expect(r.omittedCount).toBe(0); + }); + + it('truncates to cap with omitted count', () => { + const items = Array.from({ length: 50 }, (_, i) => i); + const preset: FigurePresetId = 'two_column'; + const placement: LegendPlacement = 'right'; + const cap = maxLegendItemsForLayout(preset, placement); + const r = sliceLegendItemsForLayout(items, preset, placement); + expect(r.visible.length).toBe(cap); + expect(r.omittedCount).toBe(50 - cap); + }); +}); diff --git a/packages/utils/src/visualization/publication-export/legend-slice.ts b/packages/utils/src/visualization/publication-export/legend-slice.ts new file mode 100644 index 00000000..390a2dd8 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/legend-slice.ts @@ -0,0 +1,22 @@ +import type { FigurePresetId, LegendPlacement } from './presets'; +import { maxLegendItemsForLayout } from './legend-caps'; + +export interface SlicedLegend { + visible: T[]; + omittedCount: number; +} + +export function sliceLegendItemsForLayout( + items: readonly T[], + presetId: FigurePresetId, + placement: LegendPlacement, +): SlicedLegend { + const cap = maxLegendItemsForLayout(presetId, placement); + if (items.length <= cap) { + return { visible: [...items], omittedCount: 0 }; + } + return { + visible: items.slice(0, cap), + omittedCount: items.length - cap, + }; +} diff --git a/packages/utils/src/visualization/publication-export/legend-text-layout.test.ts b/packages/utils/src/visualization/publication-export/legend-text-layout.test.ts new file mode 100644 index 00000000..dcab6549 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/legend-text-layout.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { wrapLabelToTwoLines } from './legend-text-layout'; + +function mockCtx(charWidth: number): CanvasRenderingContext2D { + return { + measureText: (s: string) => ({ width: s.length * charWidth }), + } as CanvasRenderingContext2D; +} + +describe('wrapLabelToTwoLines', () => { + it('returns single line when short', () => { + const ctx = mockCtx(5); + const r = wrapLabelToTwoLines(ctx, 'hi', 500); + expect(r.lines).toEqual(['hi']); + expect(r.truncated).toBe(false); + }); + + it('truncates long unbroken token', () => { + const ctx = mockCtx(8); + const r = wrapLabelToTwoLines(ctx, 'W'.repeat(80), 40); + expect(r.lines.length).toBe(1); + expect(r.truncated).toBe(true); + expect(r.lines[0].endsWith('…')).toBe(true); + }); + + it('wraps two lines and adds ellipsis when remainder', () => { + const ctx = mockCtx(6); + const r = wrapLabelToTwoLines(ctx, 'one two three four five six', 40); + expect(r.lines.length).toBe(2); + expect(r.truncated).toBe(true); + }); +}); diff --git a/packages/utils/src/visualization/publication-export/legend-text-layout.ts b/packages/utils/src/visualization/publication-export/legend-text-layout.ts new file mode 100644 index 00000000..09e8a7bb --- /dev/null +++ b/packages/utils/src/visualization/publication-export/legend-text-layout.ts @@ -0,0 +1,60 @@ +export interface WrappedLabel { + lines: [string] | [string, string]; + truncated: boolean; +} + +const ELLIPSIS = '…'; + +function truncateWithEllipsis(ctx: CanvasRenderingContext2D, s: string, maxW: number): string { + if (ctx.measureText(s).width <= maxW) return s; + let lo = 0; + let hi = s.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + const t = s.slice(0, mid) + ELLIPSIS; + if (ctx.measureText(t).width <= maxW) lo = mid; + else hi = mid - 1; + } + return s.slice(0, lo) + ELLIPSIS; +} + +export function wrapLabelToTwoLines( + ctx: CanvasRenderingContext2D, + text: string, + maxWidthPx: number, +): WrappedLabel { + if (ctx.measureText(text).width <= maxWidthPx) { + return { lines: [text], truncated: false }; + } + + const words = text.split(/\s+/); + let line1 = ''; + for (let i = 0; i < words.length; i++) { + const next = line1 ? `${line1} ${words[i]}` : words[i]; + if (ctx.measureText(next).width <= maxWidthPx) line1 = next; + else break; + } + if (!line1) { + return { lines: [truncateWithEllipsis(ctx, text, maxWidthPx)], truncated: true }; + } + + const rest = text.slice(line1.length).trim(); + let line2 = ''; + for (const w of rest.split(/\s+/)) { + const next = line2 ? `${line2} ${w}` : w; + if (ctx.measureText(next).width <= maxWidthPx) line2 = next; + else break; + } + if (!line2) { + line2 = truncateWithEllipsis(ctx, rest, maxWidthPx); + return { lines: [line1, line2], truncated: true }; + } + + const remainder = rest.slice(line2.length).trim(); + if (remainder) { + const budget = maxWidthPx - ctx.measureText(ELLIPSIS).width; + line2 = truncateWithEllipsis(ctx, line2, budget) + ELLIPSIS; + return { lines: [line1, line2], truncated: true }; + } + return { lines: [line1, line2], truncated: false }; +} diff --git a/packages/utils/src/visualization/publication-export/pdf-output.ts b/packages/utils/src/visualization/publication-export/pdf-output.ts new file mode 100644 index 00000000..a5f8356e --- /dev/null +++ b/packages/utils/src/visualization/publication-export/pdf-output.ts @@ -0,0 +1,19 @@ +import type { PublicationLayout } from './layout'; + +export async function downloadPublicationPdf( + rasterCanvas: HTMLCanvasElement, + layout: PublicationLayout, + fileName: string, +): Promise { + const { default: JsPDF } = await import('jspdf'); + const w = layout.figureMm.width; + const h = layout.figureMm.height; + const pdf = new JsPDF({ + orientation: w > h ? 'landscape' : 'portrait', + unit: 'mm', + format: [w, h], + }); + const img = rasterCanvas.toDataURL('image/png', 1.0); + pdf.addImage(img, 'PNG', 0, 0, w, h); + pdf.save(fileName); +} diff --git a/packages/utils/src/visualization/publication-export/pixel-rect.ts b/packages/utils/src/visualization/publication-export/pixel-rect.ts new file mode 100644 index 00000000..c7d4754e --- /dev/null +++ b/packages/utils/src/visualization/publication-export/pixel-rect.ts @@ -0,0 +1,22 @@ +import type { MmRect } from './layout'; +import { mmToPx } from './typography'; + +export interface PxRect { + x: number; + y: number; + width: number; + height: number; +} + +export function mmRectToPx(rect: MmRect, dpi: number): PxRect { + return { + x: mmToPx(rect.x, dpi), + y: mmToPx(rect.y, dpi), + width: mmToPx(rect.width, dpi), + height: mmToPx(rect.height, dpi), + }; +} + +export function rectToDrawImageArgs(rect: PxRect): [number, number, number, number] { + return [rect.x, rect.y, rect.width, rect.height]; +} diff --git a/packages/utils/src/visualization/publication-export/png-output.ts b/packages/utils/src/visualization/publication-export/png-output.ts new file mode 100644 index 00000000..c63efc6f --- /dev/null +++ b/packages/utils/src/visualization/publication-export/png-output.ts @@ -0,0 +1,7 @@ +export function downloadPng(canvas: HTMLCanvasElement, fileName: string): void { + const dataUrl = canvas.toDataURL('image/png'); + const link = document.createElement('a'); + link.href = dataUrl; + link.download = fileName; + link.click(); +} diff --git a/packages/utils/src/visualization/publication-export/presets.ts b/packages/utils/src/visualization/publication-export/presets.ts new file mode 100644 index 00000000..d7f4a18d --- /dev/null +++ b/packages/utils/src/visualization/publication-export/presets.ts @@ -0,0 +1,38 @@ +export type FigurePresetId = 'one_column' | 'two_column' | 'full_page'; +export type LegendPlacement = 'right' | 'below'; + +export interface FigurePreset { + id: FigurePresetId; + widthMm: number; + heightMm: number; + paddingMm: number; + legendBandMm: { right: number; below: number }; + scatterAspect: number; +} + +export const FIGURE_PRESETS: Record = { + one_column: { + id: 'one_column', + widthMm: 88, + heightMm: 70, + paddingMm: 2, + legendBandMm: { right: 38, below: 20 }, + scatterAspect: 4 / 3, + }, + two_column: { + id: 'two_column', + widthMm: 178, + heightMm: 95, + paddingMm: 2.5, + legendBandMm: { right: 44, below: 26 }, + scatterAspect: 4 / 3, + }, + full_page: { + id: 'full_page', + widthMm: 180, + heightMm: 140, + paddingMm: 3, + legendBandMm: { right: 50, below: 32 }, + scatterAspect: 16 / 10, + }, +}; diff --git a/packages/utils/src/visualization/publication-export/scatter-capture.ts b/packages/utils/src/visualization/publication-export/scatter-capture.ts new file mode 100644 index 00000000..8b527a07 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/scatter-capture.ts @@ -0,0 +1,58 @@ +import { validateCanvasDimensions } from '../canvas-limits'; +import type { MmRect } from './layout'; +import { mmToPx } from './typography'; + +export interface ScatterCaptureOptions { + backgroundColor: string; +} + +export type ScatterplotCaptureFn = ( + width: number, + height: number, + opts: ScatterCaptureOptions, +) => HTMLCanvasElement; + +export interface ScatterplotCaptureElement extends HTMLElement { + captureAtResolution?: ( + width: number, + height: number, + options?: { dpr?: number; backgroundColor?: string }, + ) => HTMLCanvasElement; +} + +export function scatterTargetPixels( + scatterMm: MmRect, + dpi: number, +): { width: number; height: number } { + return { + width: Math.round(mmToPx(scatterMm.width, dpi)), + height: Math.round(mmToPx(scatterMm.height, dpi)), + }; +} + +export function captureScatterForLayout( + scatterMm: MmRect, + dpi: number, + capture: ScatterplotCaptureFn, + backgroundColor: string, +): HTMLCanvasElement { + const { width, height } = scatterTargetPixels(scatterMm, dpi); + const validation = validateCanvasDimensions(width, height); + if (!validation.isValid) { + throw new Error(validation.reason ?? 'Export dimensions exceed browser limits'); + } + return capture(width, height, { backgroundColor }); +} + +export function createScatterCaptureFromElement( + el: ScatterplotCaptureElement, +): ScatterplotCaptureFn { + return (w, h, opts) => { + if (typeof el.captureAtResolution === 'function') { + return el.captureAtResolution(w, h, { dpr: 1, backgroundColor: opts.backgroundColor }); + } + throw new Error( + 'Scatterplot does not support captureAtResolution; publication export requires native WebGL capture.', + ); + }; +} diff --git a/packages/utils/src/visualization/publication-export/typography.test.ts b/packages/utils/src/visualization/publication-export/typography.test.ts new file mode 100644 index 00000000..3f07d6a6 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/typography.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { legendBodyPt, mmToPx, ptToPx, PRINT_DPI_DEFAULT } from './typography'; + +describe('ptToPx / mmToPx', () => { + it('converts 72 pt to dpi pixels', () => { + expect(ptToPx(72, 300)).toBe(300); + }); + + it('converts 25.4 mm to 300 px at 300 dpi', () => { + expect(mmToPx(25.4, 300)).toBe(300); + }); + + it('uses print dpi default', () => { + expect(ptToPx(10)).toBe((10 * PRINT_DPI_DEFAULT) / 72); + }); +}); + +describe('legendBodyPt', () => { + it('clamps to [7, 10]', () => { + expect(legendBodyPt(40, 1, 5)).toBeGreaterThanOrEqual(7); + expect(legendBodyPt(40, 1, 5)).toBeLessThanOrEqual(10); + }); + + it('uses displayed count for density', () => { + const low = legendBodyPt(30, 2, 5); + const high = legendBodyPt(30, 80, 5); + expect(high).toBeLessThanOrEqual(low); + }); +}); diff --git a/packages/utils/src/visualization/publication-export/typography.ts b/packages/utils/src/visualization/publication-export/typography.ts new file mode 100644 index 00000000..be439b71 --- /dev/null +++ b/packages/utils/src/visualization/publication-export/typography.ts @@ -0,0 +1,20 @@ +export const PRINT_DPI_DEFAULT = 300; + +export function ptToPx(pt: number, dpi: number = PRINT_DPI_DEFAULT): number { + return (pt * dpi) / 72; +} + +export function mmToPx(mm: number, dpi: number = PRINT_DPI_DEFAULT): number { + return (mm / 25.4) * dpi; +} + +export function legendBodyPt( + legendInnerHeightMm: number, + displayedItemCount: number, + headerMm: number, +): number { + const bodyMm = Math.max(4, legendInnerHeightMm - headerMm); + const density = displayedItemCount / Math.max(bodyMm, 1); + let pt = 10 - Math.min(3, density * 2); + return Math.max(7, Math.min(10, pt)); +} From b8544dc425c6b03048723e9b3b891c73fa75e748 Mon Sep 17 00:00:00 2001 From: Tobias O Date: Fri, 20 Mar 2026 14:40:51 +0100 Subject: [PATCH 02/49] refactor(utils): slim export-utils to protein IDs; drop html2canvas-pro - Remove PNG/PDF and raster fallbacks; keep createExporter for IDs only - Add generateProtspaceExportBasename for publication filenames - Add jsdom devDependency for export-utils tests (vitest environment) - Drop html2canvas-pro from deps and Rollup externals Made-with: Cursor --- packages/utils/package.json | 2 +- .../src/visualization/export-utils.test.ts | 630 ++---------- .../utils/src/visualization/export-utils.ts | 899 ++---------------- packages/utils/vite.config.ts | 3 +- pnpm-lock.yaml | 21 +- 5 files changed, 132 insertions(+), 1423 deletions(-) diff --git a/packages/utils/package.json b/packages/utils/package.json index 69e3a778..9ae3e10f 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -28,12 +28,12 @@ "license": "MIT", "dependencies": { "d3": "^7.9.0", - "html2canvas-pro": "^1.5.11", "hyparquet-writer": "^0.7.0", "jspdf": "^3.0.1" }, "devDependencies": { "@types/d3": "^7.4.3", + "jsdom": "^27.4.0", "eslint": "^9.27.0", "typescript": "^5.8.3", "vite": "^6.3.5", diff --git a/packages/utils/src/visualization/export-utils.test.ts b/packages/utils/src/visualization/export-utils.test.ts index 6c0ab4a1..38b8b567 100644 --- a/packages/utils/src/visualization/export-utils.test.ts +++ b/packages/utils/src/visualization/export-utils.test.ts @@ -1,11 +1,11 @@ +/** @vitest-environment jsdom */ + import { describe, it, expect, vi } from 'vitest'; -import { ProtSpaceExporter, createExporter } from './export-utils'; -import type { ExportableElement, ExportableData, ExportOptions } from './export-utils'; +import { createExporter, ProtSpaceExporter, generateProtspaceExportBasename } from './export-utils'; +import type { ExportableElement, ExportableData } from './export-utils'; import { LEGEND_VALUES } from './shapes'; +import { validateCanvasDimensions } from './canvas-limits'; -/** - * Mock ExportableElement for testing - */ function createMockElement(overrides: Partial = {}): ExportableElement { return { getCurrentData: () => ({ @@ -23,7 +23,7 @@ function createMockElement(overrides: Partial = {}): Exportab selectedAnnotation: 'species', selectedProjectionIndex: 0, ...overrides, - } as unknown as ExportableElement; + } as ExportableElement; } describe('createExporter', () => { @@ -32,517 +32,34 @@ describe('createExporter', () => { const exporter = createExporter(mockElement); expect(exporter).toBeInstanceOf(ProtSpaceExporter); }); - - it('creates an exporter with selected proteins', () => { - const mockElement = createMockElement(); - const exporter = createExporter(mockElement, ['P1', 'P2']); - expect(exporter).toBeInstanceOf(ProtSpaceExporter); - }); - - it('creates an exporter with all parameters', () => { - const mockElement = createMockElement(); - const exporter = createExporter(mockElement, ['P1']); - expect(exporter).toBeInstanceOf(ProtSpaceExporter); - }); }); -describe('ProtSpaceExporter.validateCanvasDimensions', () => { - it('should accept small dimensions', () => { - const result = ProtSpaceExporter['validateCanvasDimensions'](2000, 1000); - expect(result.isValid).toBe(true); - expect(result.reason).toBeUndefined(); - }); - - it('should accept 6000px dimensions', () => { - const result = ProtSpaceExporter['validateCanvasDimensions'](6000, 3000); - expect(result.isValid).toBe(true); - expect(result.reason).toBeUndefined(); - }); - - it('should accept dimensions up to ~7782px (95% of 8192)', () => { - const result = ProtSpaceExporter['validateCanvasDimensions'](7700, 4000); - expect(result.isValid).toBe(true); - }); - - it('should reject dimensions exceeding 8192px limit', () => { - const result = ProtSpaceExporter['validateCanvasDimensions'](8500, 4000); - expect(result.isValid).toBe(false); - expect(result.reason).toContain('8192px'); - }); - - it('should reject very large dimensions that exceed area limit', () => { - const result = ProtSpaceExporter['validateCanvasDimensions'](20000, 20000); - expect(result.isValid).toBe(false); - expect(result.reason).toBeDefined(); - }); - - it('should handle non-square aspect ratios', () => { - // Wide but within limits - const result1 = ProtSpaceExporter['validateCanvasDimensions'](7000, 2000); - expect(result1.isValid).toBe(true); - - // Tall but within limits - const result2 = ProtSpaceExporter['validateCanvasDimensions'](2000, 7000); - expect(result2.isValid).toBe(true); +describe('validateCanvasDimensions', () => { + it('accepts moderate dimensions', () => { + expect(validateCanvasDimensions(2000, 1000).isValid).toBe(true); }); - it('should reject if width exceeds limit', () => { - const result = ProtSpaceExporter['validateCanvasDimensions'](9000, 1000); + it('rejects dimensions exceeding safe limit', () => { + const result = validateCanvasDimensions(8500, 4000); expect(result.isValid).toBe(false); - expect(result.reason).toContain('limit'); + expect(result.reason).toContain('8192'); }); - it('should reject if height exceeds limit', () => { - const result = ProtSpaceExporter['validateCanvasDimensions'](1000, 9000); - expect(result.isValid).toBe(false); - expect(result.reason).toContain('limit'); - }); - - it('should accept maximum safe dimensions', () => { - // 95% of 8192 = 7782 + it('accepts maximum safe square dimension', () => { const maxSafe = Math.floor(8192 * 0.95); - const result = ProtSpaceExporter['validateCanvasDimensions'](maxSafe, maxSafe); - expect(result.isValid).toBe(true); - }); - - it('should accept very small dimensions', () => { - const result = ProtSpaceExporter['validateCanvasDimensions'](100, 100); - expect(result.isValid).toBe(true); - }); -}); - -describe('Export dimension calculations', () => { - describe('legend width percentage calculations', () => { - it('calculates correct legend width from percentage', () => { - // If scatterplot is 2048px and legend is 25%, total should be 2048 / 0.75 = 2730.67 - // Legend width = 2730.67 * 0.25 = 682.67 - const scatterWidth = 2048; - const legendPercent = 25 / 100; - const legendWidth = Math.round(scatterWidth * (legendPercent / (1 - legendPercent))); - - expect(legendWidth).toBe(683); // Rounded - }); - - it('handles different legend percentages', () => { - const scatterWidth = 1000; - - // 15% legend - const legend15 = Math.round(scatterWidth * (0.15 / 0.85)); - expect(legend15).toBe(176); - - // 50% legend - const legend50 = Math.round(scatterWidth * (0.5 / 0.5)); - expect(legend50).toBe(1000); - }); - }); - - describe('target dimensions for export', () => { - it('calculates target width excluding legend', () => { - const imageWidth = 2048; - const legendPercent = 25 / 100; - const targetWidth = Math.round(imageWidth * (1 - legendPercent)); - - expect(targetWidth).toBe(1536); - }); - - it('handles various image widths', () => { - const legendPercent = 0.25; - - expect(Math.round(800 * (1 - legendPercent))).toBe(600); - expect(Math.round(4096 * (1 - legendPercent))).toBe(3072); - expect(Math.round(8192 * (1 - legendPercent))).toBe(6144); - }); - }); -}); - -describe('Export scale factor calculations', () => { - const BASE_FONT_SIZE = 24; - - it('calculates correct scale factor for default font size', () => { - const fontSizePx = 24; - const scaleFactor = fontSizePx / BASE_FONT_SIZE; - expect(scaleFactor).toBe(1.0); - }); - - it('calculates correct scale factor for smaller font', () => { - const fontSizePx = 12; - const scaleFactor = fontSizePx / BASE_FONT_SIZE; - expect(scaleFactor).toBe(0.5); - }); - - it('calculates correct scale factor for larger font', () => { - const fontSizePx = 48; - const scaleFactor = fontSizePx / BASE_FONT_SIZE; - expect(scaleFactor).toBe(2.0); - }); - - it('handles minimum font size', () => { - const fontSizePx = 8; - const scaleFactor = fontSizePx / BASE_FONT_SIZE; - expect(scaleFactor).toBeCloseTo(0.333, 2); - }); - - it('handles maximum font size', () => { - const fontSizePx = 120; - const scaleFactor = fontSizePx / BASE_FONT_SIZE; - expect(scaleFactor).toBe(5.0); - }); -}); - -describe('Export options validation', () => { - it('accepts valid export options', () => { - const options: ExportOptions = { - targetWidth: 2048, - targetHeight: 1024, - legendWidthPercent: 25, - legendScaleFactor: 1.0, - includeSelection: false, - backgroundColor: '#ffffff', - }; - - expect(options.targetWidth).toBe(2048); - expect(options.targetHeight).toBe(1024); - expect(options.legendWidthPercent).toBe(25); - expect(options.legendScaleFactor).toBe(1.0); - }); - - it('handles optional properties', () => { - const minimalOptions: ExportOptions = {}; - - expect(minimalOptions.targetWidth).toBeUndefined(); - expect(minimalOptions.targetHeight).toBeUndefined(); - expect(minimalOptions.legendWidthPercent).toBeUndefined(); - }); - - it('accepts custom export name', () => { - const options: ExportOptions = { - exportName: 'my_custom_export.png', - }; - - expect(options.exportName).toBe('my_custom_export.png'); - }); - - it('accepts include shapes option', () => { - const withShapes: ExportOptions = { includeShapes: true }; - const withoutShapes: ExportOptions = { includeShapes: false }; - - expect(withShapes.includeShapes).toBe(true); - expect(withoutShapes.includeShapes).toBe(false); - }); -}); - -describe('Export filename generation', () => { - it('generates consistent filename format', () => { - // Expected format: protspace_{projection}_{annotation}_{date}.{ext} - const pattern = /^protspace_[a-z0-9_-]+_[a-z0-9_-]+_\d{4}-\d{2}-\d{2}\.(png|pdf)$/; - - expect('protspace_pca_species_2024-01-15.png').toMatch(pattern); - expect('protspace_umap_gene_2024-12-31.pdf').toMatch(pattern); - }); - - it('sanitizes projection names', () => { - // Spaces and special chars should be replaced with underscores - const sanitize = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); - - expect(sanitize('PCA 2D')).toBe('pca_2d'); - expect(sanitize('UMAP@3D')).toBe('umap_3d'); - expect(sanitize('t-SNE')).toBe('t-sne'); - }); - - it('removes dimension suffix from projection names', () => { - const removeDimensionSuffix = (name: string) => name.replace(/_[23]$/, ''); - - expect(removeDimensionSuffix('pca_2')).toBe('pca'); - expect(removeDimensionSuffix('umap_3')).toBe('umap'); - expect(removeDimensionSuffix('tsne_2d')).toBe('tsne_2d'); // Only removes _2 or _3 - }); -}); - -describe('Export constants', () => { - // These tests verify the expected export constant values - describe('canvas limits', () => { - const MAX_CANVAS_DIMENSION = 8192; - const MAX_CANVAS_AREA = 268435456; - const SAFE_DIMENSION_MARGIN = 0.95; - - it('has correct maximum dimension limit', () => { - expect(MAX_CANVAS_DIMENSION).toBe(8192); - }); - - it('has correct maximum area limit (~268M pixels)', () => { - expect(MAX_CANVAS_AREA).toBe(268435456); - }); - - it('calculates safe dimension correctly', () => { - const safeDimension = Math.floor(MAX_CANVAS_DIMENSION * SAFE_DIMENSION_MARGIN); - expect(safeDimension).toBe(7782); - }); - - it('calculates safe area correctly', () => { - const safeArea = MAX_CANVAS_AREA * SAFE_DIMENSION_MARGIN; - expect(safeArea).toBe(255013683.2); - }); - }); - - describe('PDF constants', () => { - const PDF_MARGIN = 2; - const PDF_GAP = 4; - const PDF_MAX_WIDTH = 210; // A4 width in mm - - it('has correct PDF margin', () => { - expect(PDF_MARGIN).toBe(2); - }); - - it('has correct PDF gap', () => { - expect(PDF_GAP).toBe(4); - }); - - it('has correct A4 max width', () => { - expect(PDF_MAX_WIDTH).toBe(210); - }); - }); -}); - -describe('Export aspect ratio calculations', () => { - it('maintains aspect ratio when scaling width', () => { - const originalWidth = 2048; - const originalHeight = 1024; - const newWidth = 4096; - - const aspectRatio = originalWidth / originalHeight; - const newHeight = Math.round(newWidth / aspectRatio); - - expect(newHeight).toBe(2048); - }); - - it('maintains aspect ratio when scaling height', () => { - const originalWidth = 2048; - const originalHeight = 1024; - const newHeight = 2048; - - const aspectRatio = originalWidth / originalHeight; - const newWidth = Math.round(newHeight * aspectRatio); - - expect(newWidth).toBe(4096); - }); - - it('handles non-standard aspect ratios', () => { - const width = 1920; - const height = 1080; - const aspectRatio = width / height; - - expect(aspectRatio).toBeCloseTo(16 / 9, 2); + expect(validateCanvasDimensions(maxSafe, maxSafe).isValid).toBe(true); }); }); -describe('N/A handling in export', () => { - describe('computeLegendFromData', () => { - const callComputeLegend = ( - data: ExportableData, - annotation: string, - selectedProteinIds?: string[], - ) => { - const el = createMockElement({ getCurrentData: () => data, selectedAnnotation: annotation }); - const exporter = createExporter(el); - return (exporter as unknown as Record)['computeLegendFromData']( - data, - annotation, - selectedProteinIds, - ) as Array<{ value: string; color: string; shape: string; count: number }>; - }; - - it('should use __NA__ for null annotation values', () => { - const data: ExportableData = { - protein_ids: ['P1', 'P2'], - annotations: { - species: { - values: ['human', null], - colors: ['#ff0000', '#888888'], - shapes: ['circle', 'square'], - }, - }, - annotation_data: { species: [[0], [1]] }, - }; - - const items = callComputeLegend(data, 'species'); - expect(items[0].value).toBe('human'); - expect(items[1].value).toBe(LEGEND_VALUES.NA_VALUE); - }); - - it('should count N/A items correctly', () => { - const data: ExportableData = { - protein_ids: ['P1', 'P2', 'P3'], - annotations: { - species: { - values: ['human', null], - colors: ['#ff0000', '#888888'], - shapes: ['circle', 'square'], - }, - }, - annotation_data: { species: [[0], [1], [1]] }, // P2 and P3 are N/A - }; - - const items = callComputeLegend(data, 'species'); - const naItem = items.find((it) => it.value === LEGEND_VALUES.NA_VALUE); - expect(naItem).toBeDefined(); - expect(naItem!.count).toBe(2); - }); - - it('should count correctly when filtering by selected proteins', () => { - const data: ExportableData = { - protein_ids: ['P1', 'P2', 'P3'], - annotations: { - species: { - values: ['human', null], - colors: ['#ff0000', '#888888'], - shapes: ['circle', 'square'], - }, - }, - annotation_data: { species: [[0], [1], [1]] }, - }; - - const items = callComputeLegend(data, 'species', ['P2']); // only P2 selected - const naItem = items.find((it) => it.value === LEGEND_VALUES.NA_VALUE); - expect(naItem).toBeDefined(); - expect(naItem!.count).toBe(1); - }); - }); - - describe('exportProteinIds N/A visibility', () => { - it('should exclude N/A proteins when __NA__ is hidden', () => { - const data: ExportableData = { - protein_ids: ['P1', 'P2', 'P3'], - annotations: { - species: { - values: ['human', 'mouse', null], - colors: ['#ff0000', '#00ff00', '#888888'], - shapes: ['circle', 'square', 'triangle'], - }, - }, - annotation_data: { species: [[0], [1], [2]] }, - }; - - // Simulate the visibility filtering logic from exportProteinIds - const hiddenValues = [LEGEND_VALUES.NA_VALUE]; - const hiddenSet = new Set(hiddenValues); - const annotationInfo = data.annotations.species; - const indices = data.annotation_data.species; - - const visibleIds = data.protein_ids.filter((_id, i) => { - const viArray = indices[i]; - if (!Array.isArray(viArray) || viArray.length === 0) { - return !hiddenSet.has(LEGEND_VALUES.NA_VALUE); - } - return viArray.some((vi) => { - const value = - typeof vi === 'number' && vi >= 0 && vi < annotationInfo.values.length - ? (annotationInfo.values[vi] ?? null) - : null; - const key = value === null ? LEGEND_VALUES.NA_VALUE : String(value); - return !hiddenSet.has(key); - }); - }); - - expect(visibleIds).toEqual(['P1', 'P2']); - expect(visibleIds).not.toContain('P3'); - }); - - it('should include N/A proteins when __NA__ is not hidden', () => { - const data: ExportableData = { - protein_ids: ['P1', 'P2', 'P3'], - annotations: { - species: { - values: ['human', 'mouse', null], - colors: ['#ff0000', '#00ff00', '#888888'], - shapes: ['circle', 'square', 'triangle'], - }, - }, - annotation_data: { species: [[0], [1], [2]] }, - }; - - const hiddenValues: string[] = []; - const hiddenSet = new Set(hiddenValues); - const annotationInfo = data.annotations.species; - const indices = data.annotation_data.species; - - const visibleIds = data.protein_ids.filter((_id, i) => { - const viArray = indices[i]; - if (!Array.isArray(viArray) || viArray.length === 0) { - return !hiddenSet.has(LEGEND_VALUES.NA_VALUE); - } - return viArray.some((vi) => { - const value = - typeof vi === 'number' && vi >= 0 && vi < annotationInfo.values.length - ? (annotationInfo.values[vi] ?? null) - : null; - const key = value === null ? LEGEND_VALUES.NA_VALUE : String(value); - return !hiddenSet.has(key); - }); - }); - - expect(visibleIds).toEqual(['P1', 'P2', 'P3']); - }); - - it('should exclude proteins with missing annotation data when __NA__ is hidden', () => { - const data: ExportableData = { - protein_ids: ['P1', 'P2'], - annotations: { - species: { - values: ['human'], - colors: ['#ff0000'], - shapes: ['circle'], - }, - }, - annotation_data: { species: [[0], []] }, // P2 has empty annotation data - }; - - const hiddenValues = [LEGEND_VALUES.NA_VALUE]; - const hiddenSet = new Set(hiddenValues); - - const visibleIds = data.protein_ids.filter((_id, i) => { - const viArray = data.annotation_data.species[i]; - if (!Array.isArray(viArray) || viArray.length === 0) { - return !hiddenSet.has(LEGEND_VALUES.NA_VALUE); - } - return true; - }); - - expect(visibleIds).toEqual(['P1']); - }); +describe('generateProtspaceExportBasename', () => { + it('returns protspace prefix and date', () => { + const el = createMockElement(); + const base = generateProtspaceExportBasename(el); + expect(base).toMatch(/^protspace_pca_species_\d{4}-\d{2}-\d{2}$/); }); }); -/** - * Helper to call exportProteinIds via the real exporter and capture the downloaded IDs. - * Mocks the private downloadFile method to intercept the data URI. - */ -function callExportProteinIds( - data: ExportableData, - annotation: string, - hiddenAnnotationValues: string[] = [], -): string[] { - const el = createMockElement({ - getCurrentData: () => data, - selectedAnnotation: annotation, - hiddenAnnotationValues, - }); - const exporter = createExporter(el); - - let capturedUri = ''; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(exporter as any, 'downloadFile').mockImplementation((uri: string) => { - capturedUri = uri; - }); - - exporter.exportProteinIds(); - - if (!capturedUri) return []; - const encoded = capturedUri.replace('data:text/plain;charset=utf-8,', ''); - const decoded = decodeURIComponent(encoded); - return decoded ? decoded.split('\n') : []; -} - -describe('exportProteinIds integration', () => { +describe('exportProteinIds', () => { const baseData: ExportableData = { protein_ids: ['P1', 'P2', 'P3', 'P4'], annotations: { @@ -556,73 +73,44 @@ describe('exportProteinIds integration', () => { }; it('exports all IDs when nothing is hidden', () => { - const ids = callExportProteinIds(baseData, 'species', []); - expect(ids).toEqual(['P1', 'P2', 'P3', 'P4']); - }); - - it('excludes N/A proteins when __NA__ is hidden', () => { - const ids = callExportProteinIds(baseData, 'species', [LEGEND_VALUES.NA_VALUE]); - expect(ids).toEqual(['P1', 'P2', 'P4']); - expect(ids).not.toContain('P3'); - }); - - it('excludes proteins matching a hidden regular value', () => { - const ids = callExportProteinIds(baseData, 'species', ['mouse']); - expect(ids).toEqual(['P1', 'P3', 'P4']); - expect(ids).not.toContain('P2'); - }); - - it('excludes multiple hidden values including N/A', () => { - const ids = callExportProteinIds(baseData, 'species', ['mouse', LEGEND_VALUES.NA_VALUE]); - expect(ids).toEqual(['P1', 'P4']); - }); - - it('returns no visible IDs when all values are hidden', () => { - const ids = callExportProteinIds(baseData, 'species', [ - 'human', - 'mouse', - LEGEND_VALUES.NA_VALUE, - ]); - expect(ids).toEqual([]); - }); - - it('treats proteins with empty annotation arrays as N/A', () => { - const data: ExportableData = { - protein_ids: ['P1', 'P2'], - annotations: { - species: { - values: ['human'], - colors: ['#ff0000'], - shapes: ['circle'], - }, - }, - annotation_data: { species: [[0], []] }, - }; - - const ids = callExportProteinIds(data, 'species', [LEGEND_VALUES.NA_VALUE]); - expect(ids).toEqual(['P1']); - }); - - it('exports all IDs when annotation does not exist (fallback)', () => { - const ids = callExportProteinIds(baseData, 'nonexistent', [LEGEND_VALUES.NA_VALUE]); - expect(ids).toEqual(['P1', 'P2', 'P3', 'P4']); - }); - - it('handles multi-value annotations — visible if any value is not hidden', () => { - const data: ExportableData = { - protein_ids: ['P1', 'P2'], - annotations: { - tags: { - values: ['alpha', 'beta', null], - colors: ['#f00', '#0f0', '#888'], - shapes: ['circle', 'square', 'triangle'], - }, - }, - annotation_data: { tags: [[0, 2], [1]] }, - }; - - // P1 has both 'alpha' and N/A — hiding N/A still keeps P1 because 'alpha' is visible - const ids = callExportProteinIds(data, 'tags', [LEGEND_VALUES.NA_VALUE]); - expect(ids).toEqual(['P1', 'P2']); + const el = createMockElement({ getCurrentData: () => baseData, selectedAnnotation: 'species' }); + let capturedHref = ''; + const realCreate = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const node = realCreate(tag); + if (tag === 'a') { + vi.spyOn(node, 'click').mockImplementation(function (this: HTMLAnchorElement) { + capturedHref = this.href; + }); + } + return node; + }); + createExporter(el).exportProteinIds(); + vi.mocked(document.createElement).mockRestore(); + const encoded = capturedHref.replace('data:text/plain;charset=utf-8,', ''); + expect(decodeURIComponent(encoded).split('\n')).toEqual(['P1', 'P2', 'P3', 'P4']); + }); + + it('excludes N/A when __NA__ is hidden', () => { + const el = createMockElement({ + getCurrentData: () => baseData, + selectedAnnotation: 'species', + hiddenAnnotationValues: [LEGEND_VALUES.NA_VALUE], + }); + let capturedHref = ''; + const realCreate = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const node = realCreate(tag); + if (tag === 'a') { + vi.spyOn(node, 'click').mockImplementation(function (this: HTMLAnchorElement) { + capturedHref = this.href; + }); + } + return node; + }); + createExporter(el).exportProteinIds(); + vi.mocked(document.createElement).mockRestore(); + const encoded = capturedHref.replace('data:text/plain;charset=utf-8,', ''); + expect(decodeURIComponent(encoded).split('\n')).toEqual(['P1', 'P2', 'P4']); }); }); diff --git a/packages/utils/src/visualization/export-utils.ts b/packages/utils/src/visualization/export-utils.ts index 485d2d29..07a90661 100644 --- a/packages/utils/src/visualization/export-utils.ts +++ b/packages/utils/src/visualization/export-utils.ts @@ -1,11 +1,4 @@ -/** - * Export utilities for ProtSpace visualizations - */ - -import { SHAPE_PATH_GENERATORS, LEGEND_VALUES, renderPathOnCanvas, toDisplayValue } from './shapes'; - -// PDF generation libraries are imported dynamically for better browser compatibility -declare const window: Window & typeof globalThis; +import { LEGEND_VALUES } from './shapes'; export interface ExportableData { protein_ids: string[]; @@ -29,824 +22,83 @@ export interface ExportableElement extends Element { hiddenAnnotationValues?: string[]; } -// Narrow typing for accessing the legend component from this utils package -type LegendExportItem = { - /** Category value using internal representation. N/A items use '__NA__', "Other" uses 'Other' */ - value: string; - color: string; - shape: string; - count: number; - isVisible: boolean; - zOrder: number; - extractedFromOther?: boolean; -}; -type LegendExportState = { - annotation: string; - includeShapes: boolean; - otherItemsCount: number; - items: LegendExportItem[]; -}; - export interface ExportOptions { - /** Target width for scatterplot in pixels (excluding legend) */ - targetWidth?: number; - /** Target height for scatterplot in pixels */ - targetHeight?: number; - /** Legend width as percentage of total image width (15-50%) */ - legendWidthPercent?: number; - /** Legend font/symbol scale factor (fontSizePx / 24) */ - legendScaleFactor?: number; - /** Include only selected proteins */ - includeSelection?: boolean; - /** Custom filename for export */ exportName?: string; - /** Background color */ - backgroundColor?: string; - /** Whether to render per-category shapes in legend */ - includeShapes?: boolean; } -export class ProtSpaceExporter { - // Configuration constants - private static readonly MAX_CANVAS_DIMENSION = 8192; // Browser limit - private static readonly MAX_CANVAS_AREA = 268435456; // Chrome: ~268M pixels - private static readonly SAFE_DIMENSION_MARGIN = 0.95; // 5% safety buffer - private static readonly DEFAULT_LEGEND_SCALE = 1.0; - private static readonly DEFAULT_BACKGROUND = '#ffffff'; - private static readonly PDF_MARGIN = 2; // mm - private static readonly PDF_GAP = 4; // mm - private static readonly PDF_MAX_WIDTH = 210; // A4 width in mm - - private element: ExportableElement; - private selectedProteins: string[]; - - constructor(element: ExportableElement, selectedProteins: string[] = []) { - this.element = element; - this.selectedProteins = selectedProteins; - } - - /** - * Get the scatterplot element from the DOM - */ - private getScatterplotElement(): HTMLElement | null { - const element = document.querySelector('protspace-scatterplot') as HTMLElement; - if (!element) { - console.error('Could not find protspace-scatterplot element'); - } - return element; - } - - /** - * Get export options with defaults applied - */ - private getOptionsWithDefaults(options: ExportOptions = {}) { - return { - backgroundColor: options.backgroundColor || ProtSpaceExporter.DEFAULT_BACKGROUND, - legendScaleFactor: options.legendScaleFactor ?? ProtSpaceExporter.DEFAULT_LEGEND_SCALE, - legendWidthPercent: options.legendWidthPercent ?? 25, - targetWidth: options.targetWidth, - targetHeight: options.targetHeight, - exportName: options.exportName, - includeSelection: options.includeSelection, - includeShapes: options.includeShapes, - }; - } - - /** - * Validate that dimensions are within browser canvas limits - * Returns true if dimensions are safe, false otherwise - */ - private static validateCanvasDimensions( - targetWidth: number, - targetHeight: number, - ): { isValid: boolean; reason?: string } { - const effectiveMaxDimension = Math.floor( - ProtSpaceExporter.MAX_CANVAS_DIMENSION * ProtSpaceExporter.SAFE_DIMENSION_MARGIN, - ); - - // Check dimension limits - if (targetWidth > effectiveMaxDimension || targetHeight > effectiveMaxDimension) { - return { - isValid: false, - reason: `Dimensions exceed browser limit of ${ProtSpaceExporter.MAX_CANVAS_DIMENSION}px per side. Maximum safe dimension: ${effectiveMaxDimension}px`, - }; - } - - // Check area limit - const totalPixels = targetWidth * targetHeight; - const maxSafeArea = ProtSpaceExporter.MAX_CANVAS_AREA * ProtSpaceExporter.SAFE_DIMENSION_MARGIN; - if (totalPixels > maxSafeArea) { - return { - isValid: false, - reason: `Total pixel area (${totalPixels.toLocaleString()}) exceeds browser limit (~${Math.floor(maxSafeArea).toLocaleString()} pixels)`, - }; - } - - return { isValid: true }; - } - - /** - * Generate consistent export filename: protspace_{projection}_{annotation}_{date}.{ext} - * Falls back gracefully if data is not available. - */ - private generateExportFileName(extension: string): string { - const data = this.element.getCurrentData(); - // Format date as YYYY-MM-DD only (no time) - const date = new Date().toISOString().split('T')[0]; - - // Extract projection and annotation names - const projection = data?.projections?.[this.element.selectedProjectionIndex]?.name || 'unknown'; - const annotation = this.element.selectedAnnotation || 'unknown'; - - // Sanitize names (remove spaces, special chars, convert to lowercase) - let cleanProjection = projection.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); - const cleanAnnotation = annotation.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); - - // Remove dimension suffix after sanitization (e.g., "pca_2" -> "pca", "umap_3" -> "umap") - cleanProjection = cleanProjection.replace(/_[23]$/, ''); - - return `protspace_${cleanProjection}_${cleanAnnotation}_${date}.${extension}`; - } - - /** - * Check if an element should be ignored during export (UI overlays, tooltips, etc.) - */ - private shouldIgnoreElement(element: Element): boolean { - const ignoredClasses = [ - 'projection-metadata', - 'mode-indicator', - 'isolation-indicator', - 'tooltip', - 'duplicate-spiderfy-layer', - 'dup-spiderfy', - 'overlay-container', - 'brush-container', - ]; - return ignoredClasses.some((className) => element.classList?.contains(className)); - } - - /** - * Generate legend canvas for export - */ - private generateLegendCanvas( - scatterCanvas: HTMLCanvasElement, - options: ReturnType, - ): HTMLCanvasElement { - const legendItems = this.buildLegendItems(options); - const legendExportState = this.readLegendExportState(); - const annotationNameFromLegend = legendExportState?.annotation; - - // Calculate legend width based on percentage of total image width - // If legendWidthPercent is 25%, then legend takes 25% and scatterplot takes 75% - const legendPercent = (options.legendWidthPercent || 25) / 100; - const legendWidth = Math.round(scatterCanvas.width * (legendPercent / (1 - legendPercent))); - - return this.renderLegendToCanvas( - legendItems, - legendWidth, - scatterCanvas.height, - options, - annotationNameFromLegend || this.element.selectedAnnotation, - options.legendScaleFactor, - ); - } - - /** - * Capture scatterplot element as canvas using native high-resolution WebGL rendering. - * Falls back to html2canvas for older component versions without captureAtResolution. - */ - private async captureScatterplotCanvas( - scatterplotElement: HTMLElement, - targetWidth?: number, - targetHeight?: number, - backgroundColor: string = ProtSpaceExporter.DEFAULT_BACKGROUND, - ): Promise { - // Cast to access the capture method - const scatterplot = scatterplotElement as HTMLElement & { - captureAtResolution?: ( - width: number, - height: number, - options?: { dpr?: number; backgroundColor?: string }, - ) => HTMLCanvasElement; - }; - - // Use current dimensions if not specified - const width = targetWidth ?? scatterplotElement.clientWidth; - const height = targetHeight ?? scatterplotElement.clientHeight; - - // Validate dimensions - const validation = ProtSpaceExporter.validateCanvasDimensions(width, height); - if (!validation.isValid) { - throw new Error(`Export dimensions too large: ${validation.reason}`); - } - - // Use native high-resolution capture if available - if (typeof scatterplot.captureAtResolution === 'function') { - try { - return scatterplot.captureAtResolution(width, height, { - dpr: 1, // Use DPR=1 for exact pixel output - backgroundColor, - }); - } catch { - // Fall through to fallback capture - } - } - - // Fallback: Direct canvas capture (for older component versions) - return this.fallbackCanvasCapture(scatterplotElement, width, height, backgroundColor); - } - - /** - * Fallback capture method using direct WebGL canvas copy and html2canvas. - * Used when native captureAtResolution is not available. - */ - private async fallbackCanvasCapture( - scatterplotElement: HTMLElement, - width: number, - height: number, - backgroundColor: string, - ): Promise { - // Try direct WebGL canvas capture first (faster, simpler) - const webglCanvas = scatterplotElement.querySelector( - 'canvas:not(.badges-canvas)', - ) as HTMLCanvasElement | null; - - if (webglCanvas) { - try { - const outputCanvas = document.createElement('canvas'); - outputCanvas.width = width; - outputCanvas.height = height; - - const ctx = outputCanvas.getContext('2d'); - if (!ctx) { - throw new Error('Failed to create 2D context'); - } - - // Fill with background color - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, width, height); - - // Draw WebGL canvas with scaling - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; - ctx.drawImage( - webglCanvas, - 0, - 0, - webglCanvas.width, - webglCanvas.height, - 0, - 0, - width, - height, - ); - - // Also composite badges canvas if present - const badgesCanvas = scatterplotElement.querySelector( - 'canvas.badges-canvas', - ) as HTMLCanvasElement | null; - if (badgesCanvas && badgesCanvas.width > 0) { - ctx.drawImage( - badgesCanvas, - 0, - 0, - badgesCanvas.width, - badgesCanvas.height, - 0, - 0, - width, - height, - ); - } +export function generateProtspaceExportBasename( + element: Pick< + ExportableElement, + 'getCurrentData' | 'selectedAnnotation' | 'selectedProjectionIndex' + >, +): string { + const data = element.getCurrentData(); + const date = new Date().toISOString().split('T')[0]; + const projection = data?.projections?.[element.selectedProjectionIndex]?.name || 'unknown'; + const annotation = element.selectedAnnotation || 'unknown'; + let cleanProjection = projection.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); + const cleanAnnotation = annotation.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase(); + cleanProjection = cleanProjection.replace(/_[23]$/, ''); + return `protspace_${cleanProjection}_${cleanAnnotation}_${date}`; +} - return outputCanvas; - } catch { - // Fall through to html2canvas fallback +export function exportProteinIdsFromElement(element: ExportableElement): void { + const data = element.getCurrentData(); + if (!data) { + console.error('No data available for export'); + return; + } + + const selectedAnnotation = element.selectedAnnotation; + const annotationIndices = data.annotation_data?.[selectedAnnotation]; + const annotationInfo = data.annotations?.[selectedAnnotation]; + const hiddenValues: string[] = Array.isArray(element.hiddenAnnotationValues) + ? element.hiddenAnnotationValues + : []; + + let visibleIds: string[] = []; + if (annotationIndices && annotationInfo && Array.isArray(annotationInfo.values)) { + const hiddenSet = new Set(hiddenValues); + visibleIds = data.protein_ids.filter((_id, i) => { + const viArray = annotationIndices[i]; + if (!Array.isArray(viArray) || viArray.length === 0) { + return !hiddenSet.has(LEGEND_VALUES.NA_VALUE); } - } - - // Last resort: html2canvas - const { default: html2canvas } = await import('html2canvas-pro'); - const rawCanvas = await html2canvas(scatterplotElement, { - backgroundColor, - scale: 1, - useCORS: true, - allowTaint: false, - logging: false, - width: scatterplotElement.clientWidth, - height: scatterplotElement.clientHeight, - scrollX: -window.scrollX, - scrollY: -window.scrollY, - ignoreElements: (element) => this.shouldIgnoreElement(element), + return viArray.some((vi) => { + const value: string | null = + typeof vi === 'number' && vi >= 0 && vi < annotationInfo.values.length + ? (annotationInfo.values[vi] ?? null) + : null; + const key = value === null ? LEGEND_VALUES.NA_VALUE : String(value); + return !hiddenSet.has(key); + }); }); - - // Crop the 1px border from all sides - const borderWidth = 1; - return this.cropCanvas( - rawCanvas, - borderWidth, - borderWidth, - rawCanvas.width - borderWidth * 2, - rawCanvas.height - borderWidth * 2, - ); + } else { + visibleIds = data.protein_ids || []; } - /** - * Crop a canvas to remove borders - */ - private cropCanvas( - sourceCanvas: HTMLCanvasElement, - x: number, - y: number, - width: number, - height: number, - ): HTMLCanvasElement { - const croppedCanvas = document.createElement('canvas'); - croppedCanvas.width = width; - croppedCanvas.height = height; - const ctx = croppedCanvas.getContext('2d'); - if (!ctx) { - return sourceCanvas; // Fallback to source if context unavailable - } - ctx.drawImage(sourceCanvas, x, y, width, height, 0, 0, width, height); - return croppedCanvas; - } + const idsStr = visibleIds.join('\n'); + const idsUri = `data:text/plain;charset=utf-8,${encodeURIComponent(idsStr)}`; + const fileName = 'protein_ids.txt'; - /** - * Build legend items for export from live legend state or computed data - */ - private buildLegendItems(options: ExportOptions): Array<{ - value: string; - color: string; - shape: string; - count: number; - annotation: string; - }> { - const currentData = this.element.getCurrentData(); - if (!currentData) return []; - - const legendExportState = this.readLegendExportState(); - const annotationNameFromLegend = legendExportState?.annotation; - const hiddenSet = this.readHiddenAnnotationValueKeys(); + const link = document.createElement('a'); + link.href = idsUri; + link.download = fileName; + link.click(); +} - if (legendExportState) { - const otherItemsCount = legendExportState.otherItemsCount; - return legendExportState.items - .filter((it) => it.isVisible) - .map((it) => ({ - value: toDisplayValue(it.value, otherItemsCount), - color: it.color, - shape: it.shape, - count: it.count, - annotation: annotationNameFromLegend || this.element.selectedAnnotation, - })); - } +export class ProtSpaceExporter { + private element: ExportableElement; - return this.computeLegendFromData( - currentData, - this.element.selectedAnnotation, - options.includeSelection === true ? this.selectedProteins : undefined, - ).filter((it) => { - return !hiddenSet.has(it.value); - }); + constructor(element: ExportableElement, _selectedProteins: string[] = []) { + this.element = element; } - /** - * Export protein IDs as text file - */ exportProteinIds(_options: ExportOptions = {}): void { - const data = this.element.getCurrentData(); - if (!data) { - console.error('No data available for export'); - return; - } - - // Compute visibility based on the scatterplot's current hidden annotation values - const selectedAnnotation = this.element.selectedAnnotation; - const annotationIndices = data.annotation_data?.[selectedAnnotation]; - const annotationInfo = data.annotations?.[selectedAnnotation]; - const hiddenValues: string[] = Array.isArray(this.element.hiddenAnnotationValues) - ? (this.element.hiddenAnnotationValues as string[]) - : []; - - let visibleIds: string[] = []; - if (annotationIndices && annotationInfo && Array.isArray(annotationInfo.values)) { - const hiddenSet = new Set(hiddenValues); - visibleIds = data.protein_ids.filter((_id, i) => { - const viArray = annotationIndices[i]; - // A protein is visible if at least one of its annotation values is not hidden - if (!Array.isArray(viArray) || viArray.length === 0) { - return !hiddenSet.has(LEGEND_VALUES.NA_VALUE); - } - return viArray.some((vi) => { - const value: string | null = - typeof vi === 'number' && vi >= 0 && vi < annotationInfo.values.length - ? (annotationInfo.values[vi] ?? null) - : null; - const key = value === null ? LEGEND_VALUES.NA_VALUE : String(value); - return !hiddenSet.has(key); - }); - }); - } else { - // Fallback: if we cannot determine annotation visibility, export all ids - visibleIds = data.protein_ids || []; - } - - const idsStr = visibleIds.join('\n'); - const idsUri = `data:text/plain;charset=utf-8,${encodeURIComponent(idsStr)}`; - const fileName = 'protein_ids.txt'; - - this.downloadFile(idsUri, fileName); - } - - /** - * Export visualization as a single PNG with a programmatic legend on the right (legend width = 1/5 of total) - */ - async exportPNG(options: ExportOptions = {}): Promise { - try { - await this.exportCombinedPNG(options); - } catch (error) { - console.error('PNG export failed:', error); - throw error; - } - } - - /** - * Create a combined PNG with scatterplot and legend side-by-side - */ - private async exportCombinedPNG(options: ExportOptions = {}): Promise { - const scatterplotElement = this.getScatterplotElement(); - if (!scatterplotElement) return; - - const opts = this.getOptionsWithDefaults(options); - - // Capture scatterplot at target dimensions with quality scaling - const scatterCanvas = await this.captureScatterplotCanvas( - scatterplotElement, - opts.targetWidth, - opts.targetHeight, - opts.backgroundColor, - ); - - // Generate legend canvas - const legendCanvas = this.generateLegendCanvas(scatterCanvas, opts); - - // Composite scatterplot and legend - // Use actual legend width to ensure it's not cut off - const combinedWidth = scatterCanvas.width + legendCanvas.width; - const combinedHeight = Math.max(scatterCanvas.height, legendCanvas.height); - const outCanvas = document.createElement('canvas'); - outCanvas.width = combinedWidth; - outCanvas.height = combinedHeight; - const ctx = outCanvas.getContext('2d'); - if (!ctx) { - console.error('Could not get 2D context for output canvas'); - return; - } - - ctx.fillStyle = opts.backgroundColor; - ctx.fillRect(0, 0, combinedWidth, combinedHeight); - ctx.drawImage(scatterCanvas, 0, 0); - ctx.drawImage(legendCanvas, scatterCanvas.width, 0); - - // Download - const dataUrl = outCanvas.toDataURL('image/png'); - const fileName = opts.exportName || this.generateExportFileName('png'); - this.downloadFile(dataUrl, fileName); - } - - /** - * Compute legend items (value, color, shape, count) from raw data and selected annotation. - * If selectedProteinIds provided, counts will be based on the selection subset. - */ - private computeLegendFromData( - data: ExportableData, - selectedAnnotation: string, - selectedProteinIds?: string[], - ): Array<{ - value: string; - color: string; - shape: string; - count: number; - annotation: string; - }> { - const annotation = selectedAnnotation || Object.keys(data.annotations || {})[0] || ''; - const annotationInfo = data.annotations?.[annotation]; - const indices = data.annotation_data?.[annotation]; - if (!annotationInfo || !indices || !Array.isArray(annotationInfo.values)) { - return []; - } - - // Map protein id -> index for selection filtering - let allowedIndexSet: Set | null = null; - if (selectedProteinIds && Array.isArray(selectedProteinIds) && selectedProteinIds.length > 0) { - const idToIndex = new Map(); - for (let i = 0; i < data.protein_ids.length; i += 1) { - idToIndex.set(data.protein_ids[i], i); - } - allowedIndexSet = new Set(); - selectedProteinIds.forEach((pid) => { - const idx = idToIndex.get(pid); - if (typeof idx === 'number') allowedIndexSet!.add(idx); - }); - } - - const counts = new Array(annotationInfo.values.length).fill(0) as number[]; - for (let i = 0; i < indices.length; i += 1) { - if (allowedIndexSet && !allowedIndexSet.has(i)) continue; - const viArray = indices[i]; - if (Array.isArray(viArray)) { - for (const vi of viArray) { - if (typeof vi === 'number' && vi >= 0 && vi < counts.length) { - counts[vi] += 1; - } - } - } - } - - const items: Array<{ - value: string; - color: string; - shape: string; - count: number; - annotation: string; - }> = []; - for (let i = 0; i < annotationInfo.values.length; i += 1) { - const value = annotationInfo.values[i] ?? LEGEND_VALUES.NA_VALUE; - const color = annotationInfo.colors?.[i] ?? '#888'; - const shape = annotationInfo.shapes?.[i] ?? 'circle'; - const count = counts[i] ?? 0; - items.push({ value: String(value), color, shape, count, annotation }); - } - return items; - } - - /** - * Render a legend using Canvas 2D API (no dependency on legend component) - */ - private renderLegendToCanvas( - items: Array<{ - value: string; - color: string; - shape: string; - count: number; - annotation: string; - }>, - width: number, - height: number, - options: ExportOptions, - overrideAnnotationName?: string, - scaleFactor: number = 1.0, - ): HTMLCanvasElement { - const canvas = document.createElement('canvas'); - canvas.width = Math.max(100, Math.floor(width)); - canvas.height = Math.max(100, Math.floor(height)); - const ctx = canvas.getContext('2d')!; - - const basePadding = 24; - const baseHeaderHeight = 60; - const baseItemHeight = 56; - const baseSymbolSize = 28; - const baseHeaderFont = 28; - const baseItemFont = 24; - - const padding = basePadding * scaleFactor; - const headerHeight = baseHeaderHeight * scaleFactor; - const itemHeight = baseItemHeight * scaleFactor; - const symbolSize = baseSymbolSize * scaleFactor; - const headerFontSize = baseHeaderFont * scaleFactor; - const itemFontSize = baseItemFont * scaleFactor; - const bg = options.backgroundColor || '#ffffff'; - - // Compute required height and scale vertically if needed - const requiredHeight = padding + headerHeight + items.length * itemHeight + padding; - const scaleY = Math.min(1, canvas.height / requiredHeight); - - // No horizontal scaling - canvas width is already sized correctly - ctx.save(); - ctx.scale(1, scaleY); - - ctx.fillStyle = bg; - ctx.fillRect(0, 0, canvas.width, canvas.height / scaleY); - - ctx.fillStyle = '#1f2937'; - ctx.font = `600 ${headerFontSize}px Arial, sans-serif`; - ctx.textBaseline = 'middle'; - const headerLabel = overrideAnnotationName || items[0]?.annotation || 'Legend'; - ctx.fillText(`${headerLabel}`, padding, padding + headerHeight / 2); - - // Items - let y = padding + headerHeight; - const includeShapes = - typeof options.includeShapes === 'boolean' - ? options.includeShapes - : this.readUseShapesFromScatterplot(); - - for (const it of items) { - const cx = padding + symbolSize / 2; - const cy = y + itemHeight / 2; - if (includeShapes) { - this.drawCanvasSymbol(ctx, it.shape, it.color, cx, cy, symbolSize); - } else { - // Draw a simple color swatch (circle) to match no-shape mode - ctx.save(); - ctx.fillStyle = it.color || '#888'; - ctx.strokeStyle = '#333'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(cx, cy, symbolSize / 2, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.restore(); - } - - // Draw label (left-aligned) - ctx.fillStyle = '#1f2937'; - ctx.font = `500 ${itemFontSize}px Arial, sans-serif`; - ctx.textBaseline = 'middle'; - ctx.textAlign = 'left'; - const textOffset = 8 * scaleFactor; - ctx.fillText(toDisplayValue(it.value), padding + symbolSize + textOffset, cy); - - // Draw count (right-aligned) - const countStr = String(it.count); - ctx.font = `500 ${itemFontSize}px Arial, sans-serif`; - ctx.fillStyle = '#4b5563'; - ctx.textAlign = 'right'; - const countX = canvas.width - padding; - ctx.fillText(countStr, countX, cy); - - y += itemHeight; - } - - ctx.restore(); - return canvas; - } - - /** - * Read the current `useShapes` flag from the live `protspace-scatterplot` web component. - * Defaults to false if not available so exported legends match the common default. - */ - private readUseShapesFromScatterplot(): boolean { - const el = document.querySelector('protspace-scatterplot') as - | (Element & { useShapes?: boolean }) - | null; - if (el && typeof el.useShapes === 'boolean') { - return Boolean(el.useShapes); - } - return false; - } - - /** - * Read hidden annotation values from the live scatterplot so exports mirror visibility. - * Returns keys in the same format used internally (e.g., "__NA__" for N/A values). - */ - private readHiddenAnnotationValueKeys(): Set { - try { - const raw = Array.isArray(this.element.hiddenAnnotationValues) - ? (this.element.hiddenAnnotationValues as string[]) - : []; - return new Set(raw); - } catch (_e) { - console.error('Error reading hidden annotation values:', _e); - return new Set(); - } - } - - /** - * Draw a symbol on canvas using the same custom shapes as the legend component and WebGL renderer - */ - private drawCanvasSymbol( - ctx: CanvasRenderingContext2D, - shape: string, - color: string, - cx: number, - cy: number, - size: number, - ) { - const shapeKey = (shape || 'circle').toLowerCase(); - const pathGenerator = SHAPE_PATH_GENERATORS[shapeKey] || SHAPE_PATH_GENERATORS.circle; - const pathString = pathGenerator(size); - - // All shapes use the same rendering: filled with color, stroked with default stroke color - renderPathOnCanvas(ctx, pathString, cx, cy, color || '#888', '#394150', 1); - } - - /** - * Read legend export state from the live legend component if available. - */ - private readLegendExportState(): LegendExportState | null { - const legendEl = document.querySelector('protspace-legend') as - | (Element & { getLegendExportData?: () => LegendExportState }) - | null; - try { - if (legendEl && typeof legendEl.getLegendExportData === 'function') { - const state = legendEl.getLegendExportData(); - if (state && Array.isArray(state.items)) return state as LegendExportState; - } - } catch (_e) { - console.error('Error reading legend export state:', _e); - } - return null; - } - - /** - * Export visualization as a single PDF file (scatterplot on first page, legend on second if present) - */ - async exportPDF(options: ExportOptions = {}): Promise { - try { - await this.exportCombinedPDF(options); - } catch (error) { - console.error('PDF export failed:', error); - throw error; - } - } - - /** - * Create a single-page PDF with scatterplot and legend side-by-side - */ - private async exportCombinedPDF(options: ExportOptions = {}): Promise { - const scatterplotElement = this.getScatterplotElement(); - if (!scatterplotElement) return; - - const opts = this.getOptionsWithDefaults(options); - const { default: jsPDF } = await import('jspdf'); - - // Capture scatterplot at target dimensions with quality scaling - const scatterCanvas = await this.captureScatterplotCanvas( - scatterplotElement, - opts.targetWidth, - opts.targetHeight, - opts.backgroundColor, - ); - - // Generate legend canvas - const legendCanvas = this.generateLegendCanvas(scatterCanvas, opts); - - // Convert to images - const scatterImg = scatterCanvas.toDataURL('image/png', 1.0); - const legendImg = legendCanvas.toDataURL('image/png', 1.0); - const scatterRatio = scatterCanvas.width / scatterCanvas.height; - const legendRatio = legendCanvas.width / legendCanvas.height; - - // Calculate PDF dimensions - const margin = ProtSpaceExporter.PDF_MARGIN; - const gap = ProtSpaceExporter.PDF_GAP; - const maxWidth = ProtSpaceExporter.PDF_MAX_WIDTH - 2 * margin; - const availableWidth = maxWidth - gap; - - // Distribute width proportionally based on aspect ratios - const totalRatioWidth = scatterRatio + legendRatio; - const scatterTargetW = availableWidth * (scatterRatio / totalRatioWidth); - const legendTargetW = availableWidth * (legendRatio / totalRatioWidth); - - // Calculate heights maintaining aspect ratios - const scatterTargetH = scatterTargetW / scatterRatio; - const legendTargetH = legendTargetW / legendRatio; - const contentHeight = Math.max(scatterTargetH, legendTargetH); - - // Create custom page size that fits content exactly - const pdfWidth = maxWidth + 2 * margin; - const pdfHeight = contentHeight + 2 * margin; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const pdf: any = new (jsPDF as any)({ - orientation: pdfWidth > pdfHeight ? 'landscape' : 'portrait', - unit: 'mm', - format: [pdfWidth, pdfHeight], - }); - - pdf.setProperties({ - title: 'ProtSpace Visualization', - subject: 'ProtSpace export', - author: 'ProtSpace', - creator: 'ProtSpace', - }); - - // Place images - const xScatter = margin; - const xLegend = xScatter + scatterTargetW + gap; - pdf.addImage(scatterImg, 'PNG', xScatter, margin, scatterTargetW, scatterTargetH); - pdf.addImage(legendImg, 'PNG', xLegend, margin, legendTargetW, legendTargetH); - - const fileName = opts.exportName || this.generateExportFileName('pdf'); - pdf.save(fileName); - } - - /** - * Download file helper - */ - private downloadFile(url: string, filename: string): void { - const link = document.createElement('a'); - link.href = url; - link.download = filename; - link.click(); + exportProteinIdsFromElement(this.element); } } -/** - * Convenience function to create an exporter instance - */ export function createExporter( element: ExportableElement, selectedProteins: string[] = [], @@ -854,37 +106,12 @@ export function createExporter( return new ProtSpaceExporter(element, selectedProteins); } -/** - * Quick export functions for common use cases - */ export const exportUtils = { - /** - * Export protein IDs - */ exportProteinIds: ( element: ExportableElement, - selectedProteins?: string[], - options?: ExportOptions, + _selectedProteins?: string[], + _options?: ExportOptions, ) => { - const exporter = createExporter(element, selectedProteins); - exporter.exportProteinIds(options); + exportProteinIdsFromElement(element); }, - - /** - * Export as PNG - */ - exportPNG: async (element: ExportableElement, options?: ExportOptions) => { - const exporter = createExporter(element); - return exporter.exportPNG(options); - }, - - /** - * Export as PDF - */ - exportPDF: async (element: ExportableElement, options?: ExportOptions) => { - const exporter = createExporter(element); - return exporter.exportPDF(options); - }, - - // SVG export removed per requirements }; diff --git a/packages/utils/vite.config.ts b/packages/utils/vite.config.ts index 6461f871..81b7a5ca 100644 --- a/packages/utils/vite.config.ts +++ b/packages/utils/vite.config.ts @@ -25,12 +25,11 @@ export default defineConfig({ fileName: (format) => `index.${format === 'es' ? 'esm.js' : 'js'}`, }, rollupOptions: { - external: ['lit', 'd3', 'html2canvas-pro', 'jspdf'], + external: ['lit', 'd3', 'jspdf'], output: { globals: { lit: 'Lit', d3: 'D3', - 'html2canvas-pro': 'html2canvas', jspdf: 'jspdf', }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5aed14d3..7b499ba8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,9 +184,6 @@ importers: d3: specifier: ^7.9.0 version: 7.9.0 - html2canvas-pro: - specifier: ^1.5.11 - version: 1.5.12 hyparquet-writer: specifier: ^0.7.0 version: 0.7.0 @@ -200,6 +197,9 @@ importers: eslint: specifier: ^9.27.0 version: 9.37.0(jiti@2.6.1) + jsdom: + specifier: ^27.4.0 + version: 27.4.0 typescript: specifier: ^5.8.3 version: 5.9.3 @@ -2796,10 +2796,6 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - html2canvas-pro@1.5.12: - resolution: {integrity: sha512-egtJIe6YXMKSLX/ls400OJD6tzEVtATJOE++mnXmxMWyqcu9HDXDoLiWeXnGv45QW2ZaIiDlXw46Gxqrqw6SEw==} - engines: {node: '>=16.0.0'} - html2canvas@1.4.1: resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} engines: {node: '>=8.0.0'} @@ -5994,7 +5990,8 @@ snapshots: balanced-match@1.0.2: {} - base64-arraybuffer@1.0.2: {} + base64-arraybuffer@1.0.2: + optional: true baseline-browser-mapping@2.8.28: {} @@ -6148,6 +6145,7 @@ snapshots: css-line-break@2.1.0: dependencies: utrie: 1.0.2 + optional: true css-tree@3.1.0: dependencies: @@ -6740,11 +6738,6 @@ snapshots: html-void-elements@3.0.0: {} - html2canvas-pro@1.5.12: - dependencies: - css-line-break: 2.1.0 - text-segmentation: 1.0.3 - html2canvas@1.4.1: dependencies: css-line-break: 2.1.0 @@ -7705,6 +7698,7 @@ snapshots: text-segmentation@1.0.3: dependencies: utrie: 1.0.2 + optional: true thenify-all@1.6.0: dependencies: @@ -7845,6 +7839,7 @@ snapshots: utrie@1.0.2: dependencies: base64-arraybuffer: 1.0.2 + optional: true vfile-message@4.0.3: dependencies: From 785b8247b75d16bc33284d7df74ff6d42e1d9960 Mon Sep 17 00:00:00 2001 From: Tobias O Date: Fri, 20 Mar 2026 14:41:09 +0100 Subject: [PATCH 03/49] feat(core): publication export UI and shared WebGL canvas limits - Replace pixel sliders with figure preset and legend placement - Emit publication mode on PNG/PDF export; persist preset fields - Use MAX_CANVAS_DIMENSION / MAX_CANVAS_AREA from @protspace/utils in renderer - Update control bar and persistence tests; clarify legend export docstring Made-with: Cursor --- .../control-bar/control-bar-helpers.test.ts | 114 +---- .../control-bar/control-bar-helpers.ts | 93 +--- .../src/components/control-bar/control-bar.ts | 431 ++++++------------ .../export-persistence-controller.test.ts | 2 + packages/core/src/components/legend/legend.ts | 2 +- .../webgl/renderer/webgl-renderer.ts | 13 +- 6 files changed, 172 insertions(+), 483 deletions(-) diff --git a/packages/core/src/components/control-bar/control-bar-helpers.test.ts b/packages/core/src/components/control-bar/control-bar-helpers.test.ts index 6b24c321..d3e5f5a1 100644 --- a/packages/core/src/components/control-bar/control-bar-helpers.test.ts +++ b/packages/core/src/components/control-bar/control-bar-helpers.test.ts @@ -26,115 +26,35 @@ describe('control-bar-helpers', () => { describe('EXPORT_DEFAULTS', () => { it('has correct default values', () => { expect(EXPORT_DEFAULTS.FORMAT).toBe('png'); - expect(EXPORT_DEFAULTS.IMAGE_WIDTH).toBe(2048); - expect(EXPORT_DEFAULTS.IMAGE_HEIGHT).toBe(1024); - expect(EXPORT_DEFAULTS.LEGEND_WIDTH_PERCENT).toBe(25); - expect(EXPORT_DEFAULTS.LEGEND_FONT_SIZE_PX).toBe(24); - expect(EXPORT_DEFAULTS.BASE_FONT_SIZE).toBe(24); - expect(EXPORT_DEFAULTS.MIN_LEGEND_FONT_SIZE_PX).toBe(8); - expect(EXPORT_DEFAULTS.MAX_LEGEND_FONT_SIZE_PX).toBe(120); - expect(EXPORT_DEFAULTS.LOCK_ASPECT_RATIO).toBe(true); - }); - - it('is defined and not undefined', () => { - expect(EXPORT_DEFAULTS).toBeDefined(); - expect(typeof EXPORT_DEFAULTS).toBe('object'); - }); - - it('has all required keys', () => { - const requiredKeys = [ - 'FORMAT', - 'IMAGE_WIDTH', - 'IMAGE_HEIGHT', - 'LEGEND_WIDTH_PERCENT', - 'LEGEND_FONT_SIZE_PX', - 'BASE_FONT_SIZE', - 'MIN_LEGEND_FONT_SIZE_PX', - 'MAX_LEGEND_FONT_SIZE_PX', - 'LOCK_ASPECT_RATIO', - ]; - - requiredKeys.forEach((key) => { - expect(EXPORT_DEFAULTS).toHaveProperty(key); - }); + expect(EXPORT_DEFAULTS.PUBLICATION_PRESET).toBe('two_column'); + expect(EXPORT_DEFAULTS.LEGEND_PLACEMENT).toBe('right'); + expect(EXPORT_DEFAULTS.LEGACY_IMAGE_WIDTH).toBe(2048); + expect(EXPORT_DEFAULTS.LEGACY_IMAGE_HEIGHT).toBe(1024); }); - it('creates default export options including parquet toggles', () => { + it('creates default export options including parquet toggles and publication fields', () => { expect(createDefaultExportOptions()).toEqual({ - imageWidth: EXPORT_DEFAULTS.IMAGE_WIDTH, - imageHeight: EXPORT_DEFAULTS.IMAGE_HEIGHT, - lockAspectRatio: EXPORT_DEFAULTS.LOCK_ASPECT_RATIO, - legendWidthPercent: EXPORT_DEFAULTS.LEGEND_WIDTH_PERCENT, - legendFontSizePx: EXPORT_DEFAULTS.LEGEND_FONT_SIZE_PX, + imageWidth: EXPORT_DEFAULTS.LEGACY_IMAGE_WIDTH, + imageHeight: EXPORT_DEFAULTS.LEGACY_IMAGE_HEIGHT, + lockAspectRatio: true, + legendWidthPercent: EXPORT_DEFAULTS.LEGACY_LEGEND_WIDTH_PERCENT, + legendFontSizePx: EXPORT_DEFAULTS.LEGACY_LEGEND_FONT_SIZE_PX, includeLegendSettings: true, includeExportOptions: true, + publicationPresetId: EXPORT_DEFAULTS.PUBLICATION_PRESET, + legendPlacement: EXPORT_DEFAULTS.LEGEND_PLACEMENT, }); }); - it('has valid numeric ranges', () => { - expect(EXPORT_DEFAULTS.IMAGE_WIDTH).toBeGreaterThan(0); - expect(EXPORT_DEFAULTS.IMAGE_HEIGHT).toBeGreaterThan(0); - expect(EXPORT_DEFAULTS.LEGEND_WIDTH_PERCENT).toBeGreaterThan(0); - expect(EXPORT_DEFAULTS.LEGEND_WIDTH_PERCENT).toBeLessThanOrEqual(100); - expect(EXPORT_DEFAULTS.MIN_LEGEND_FONT_SIZE_PX).toBeLessThan( - EXPORT_DEFAULTS.MAX_LEGEND_FONT_SIZE_PX, - ); - }); - - describe('export dimension calculations', () => { - it('calculates correct legend percentage', () => { - const legendPercent = EXPORT_DEFAULTS.LEGEND_WIDTH_PERCENT / 100; - expect(legendPercent).toBe(0.25); - }); - - it('calculates correct target width for scatterplot', () => { - const legendPercent = EXPORT_DEFAULTS.LEGEND_WIDTH_PERCENT / 100; - const targetWidth = Math.round(EXPORT_DEFAULTS.IMAGE_WIDTH * (1 - legendPercent)); - expect(targetWidth).toBe(1536); // 2048 * 0.75 = 1536 - }); - - it('calculates correct legend scale factor', () => { - const scaleFactor = EXPORT_DEFAULTS.LEGEND_FONT_SIZE_PX / EXPORT_DEFAULTS.BASE_FONT_SIZE; - expect(scaleFactor).toBe(1.0); // 24 / 24 = 1.0 - }); - - it('calculates scale factor for minimum font size', () => { - const scaleFactor = - EXPORT_DEFAULTS.MIN_LEGEND_FONT_SIZE_PX / EXPORT_DEFAULTS.BASE_FONT_SIZE; - expect(scaleFactor).toBeCloseTo(0.333, 2); // 8 / 24 ≈ 0.333 - }); - - it('calculates scale factor for maximum font size', () => { - const scaleFactor = - EXPORT_DEFAULTS.MAX_LEGEND_FONT_SIZE_PX / EXPORT_DEFAULTS.BASE_FONT_SIZE; - expect(scaleFactor).toBe(5.0); // 120 / 24 = 5.0 - }); - }); - - describe('aspect ratio calculations with defaults', () => { - it('calculates default aspect ratio', () => { - const aspectRatio = EXPORT_DEFAULTS.IMAGE_WIDTH / EXPORT_DEFAULTS.IMAGE_HEIGHT; - expect(aspectRatio).toBe(2); // 2048 / 1024 = 2 - }); - + describe('aspect ratio helpers', () => { it('maintains aspect ratio when doubling width', () => { - const newWidth = EXPORT_DEFAULTS.IMAGE_WIDTH * 2; - const newHeight = calculateHeightFromWidth( - newWidth, - EXPORT_DEFAULTS.IMAGE_WIDTH, - EXPORT_DEFAULTS.IMAGE_HEIGHT, - ); - expect(newHeight).toBe(2048); // 1024 * 2 + const newHeight = calculateHeightFromWidth(4096, 2048, 1024); + expect(newHeight).toBe(2048); }); it('maintains aspect ratio when doubling height', () => { - const newHeight = EXPORT_DEFAULTS.IMAGE_HEIGHT * 2; - const newWidth = calculateWidthFromHeight( - newHeight, - EXPORT_DEFAULTS.IMAGE_HEIGHT, - EXPORT_DEFAULTS.IMAGE_WIDTH, - ); - expect(newWidth).toBe(4096); // 2048 * 2 + const newWidth = calculateWidthFromHeight(2048, 1024, 2048); + expect(newWidth).toBe(4096); }); }); }); diff --git a/packages/core/src/components/control-bar/control-bar-helpers.ts b/packages/core/src/components/control-bar/control-bar-helpers.ts index 082b4d1a..7606f638 100644 --- a/packages/core/src/components/control-bar/control-bar-helpers.ts +++ b/packages/core/src/components/control-bar/control-bar-helpers.ts @@ -1,41 +1,34 @@ -/** - * Helper functions for control bar component - * These functions contain the core logic extracted from the component for better testability - */ - -import type { PersistedExportOptions } from '@protspace/utils'; +import type { + PersistedExportOptions, + PublicationFigurePresetId, + PublicationLegendPlacementId, +} from '@protspace/utils'; import type { ProtspaceData } from './types'; -/** - * Export default settings - */ export const EXPORT_DEFAULTS = { FORMAT: 'png' as const, - IMAGE_WIDTH: 2048, - IMAGE_HEIGHT: 1024, - LEGEND_WIDTH_PERCENT: 25, - LEGEND_FONT_SIZE_PX: 24, - BASE_FONT_SIZE: 24, - MIN_LEGEND_FONT_SIZE_PX: 8, - MAX_LEGEND_FONT_SIZE_PX: 120, - LOCK_ASPECT_RATIO: true, + PUBLICATION_PRESET: 'two_column' as PublicationFigurePresetId, + LEGEND_PLACEMENT: 'right' as PublicationLegendPlacementId, + LEGACY_IMAGE_WIDTH: 2048, + LEGACY_IMAGE_HEIGHT: 1024, + LEGACY_LEGEND_WIDTH_PERCENT: 25, + LEGACY_LEGEND_FONT_SIZE_PX: 24, }; export function createDefaultExportOptions(): PersistedExportOptions { return { - imageWidth: EXPORT_DEFAULTS.IMAGE_WIDTH, - imageHeight: EXPORT_DEFAULTS.IMAGE_HEIGHT, - lockAspectRatio: EXPORT_DEFAULTS.LOCK_ASPECT_RATIO, - legendWidthPercent: EXPORT_DEFAULTS.LEGEND_WIDTH_PERCENT, - legendFontSizePx: EXPORT_DEFAULTS.LEGEND_FONT_SIZE_PX, + imageWidth: EXPORT_DEFAULTS.LEGACY_IMAGE_WIDTH, + imageHeight: EXPORT_DEFAULTS.LEGACY_IMAGE_HEIGHT, + lockAspectRatio: true, + legendWidthPercent: EXPORT_DEFAULTS.LEGACY_LEGEND_WIDTH_PERCENT, + legendFontSizePx: EXPORT_DEFAULTS.LEGACY_LEGEND_FONT_SIZE_PX, includeLegendSettings: true, includeExportOptions: true, + publicationPresetId: EXPORT_DEFAULTS.PUBLICATION_PRESET, + legendPlacement: EXPORT_DEFAULTS.LEGEND_PLACEMENT, }; } -/** - * Calculate new height when width changes with locked aspect ratio - */ export function calculateHeightFromWidth( newWidth: number, oldWidth: number, @@ -46,9 +39,6 @@ export function calculateHeightFromWidth( return Math.round(currentHeight * ratio); } -/** - * Calculate new width when height changes with locked aspect ratio - */ export function calculateWidthFromHeight( newHeight: number, oldHeight: number, @@ -59,9 +49,6 @@ export function calculateWidthFromHeight( return Math.round(currentWidth * ratio); } -/** - * Check if projection is 3D based on metadata - */ export function isProjection3D( projectionName: string, projectionsMeta: Array<{ name: string; metadata?: { dimension?: 2 | 3 } }>, @@ -70,9 +57,6 @@ export function isProjection3D( return meta?.metadata?.dimension === 3; } -/** - * Get appropriate plane for projection - */ export function getProjectionPlane( is3D: boolean, currentPlane: 'xy' | 'xz' | 'yz', @@ -80,25 +64,16 @@ export function getProjectionPlane( return is3D ? currentPlane : 'xy'; } -/** - * Filter configuration type - */ export interface FilterConfig { enabled: boolean; values: (string | null)[]; } -/** - * Active filter type - */ export interface ActiveFilter { annotation: string; values: (string | null)[]; } -/** - * Extract active filters from filter configuration - */ export function getActiveFilters(filterConfig: Record): ActiveFilter[] { return Object.entries(filterConfig) .filter(([, cfg]) => cfg.enabled && Array.isArray(cfg.values) && cfg.values.length > 0) @@ -108,9 +83,6 @@ export function getActiveFilters(filterConfig: Record): Ac })); } -/** - * Check if a protein matches all active filters - */ export function doesProteinMatchFilters( proteinIndex: number, activeFilters: ActiveFilter[], @@ -124,7 +96,6 @@ export function doesProteinMatchFilters( return false; } - // Handle both number[] and number[][] formats const annotationValue = Array.isArray(annotationIdxData[proteinIndex]) ? (annotationIdxData[proteinIndex] as number[])[0] : (annotationIdxData as number[])[proteinIndex]; @@ -142,10 +113,6 @@ export function doesProteinMatchFilters( return true; } -/** - * Apply filters to data and return membership array - * Returns array where 0 = Filtered Proteins, 1 = Other Proteins - */ export function applyFiltersToData(data: ProtspaceData, activeFilters: ActiveFilter[]): number[] { const numProteins = Array.isArray(data.protein_ids) ? data.protein_ids.length : 0; const indices: number[] = new Array(numProteins); @@ -158,9 +125,6 @@ export function applyFiltersToData(data: ProtspaceData, activeFilters: ActiveFil return indices; } -/** - * Create custom annotation from filter results - */ export function createCustomAnnotation(): { values: string[]; colors: string[]; @@ -173,16 +137,10 @@ export function createCustomAnnotation(): { }; } -/** - * Validate selection mode based on data size - */ export function shouldDisableSelection(dataSize: number): boolean { return dataSize <= 1; } -/** - * Create selection disabled message - */ export function getSelectionDisabledMessage(reason: string, dataSize: number): string { if (reason === 'insufficient-data') { return `Selection mode disabled: Only ${dataSize} point${dataSize !== 1 ? 's' : ''} remaining`; @@ -190,9 +148,6 @@ export function getSelectionDisabledMessage(reason: string, dataSize: number): s return 'Selection mode disabled'; } -/** - * Check if two filter configurations are equal - */ export function areFilterConfigsEqual( config1: Record, config2: Record, @@ -214,9 +169,6 @@ export function areFilterConfigsEqual( return true; } -/** - * Initialize filter config for annotations - */ export function initializeFilterConfig( annotations: string[], existingConfig: Record, @@ -230,9 +182,6 @@ export function initializeFilterConfig( return nextConfig; } -/** - * Toggle protein selection (add or remove) - */ export function toggleProteinSelection(proteinId: string, currentSelection: string[]): string[] { const currentSet = new Set(currentSelection); if (currentSet.has(proteinId)) { @@ -243,9 +192,6 @@ export function toggleProteinSelection(proteinId: string, currentSelection: stri return Array.from(currentSet); } -/** - * Merge multiple protein selections (for brush selection) - */ export function mergeProteinSelections( currentSelection: string[], newSelections: string[], @@ -255,9 +201,6 @@ export function mergeProteinSelections( return Array.from(merged); } -/** - * Validate annotation values - */ export function validateAnnotationValues( values: (string | null)[], availableValues: (string | null)[], diff --git a/packages/core/src/components/control-bar/control-bar.ts b/packages/core/src/components/control-bar/control-bar.ts index 1c2bfa5f..9fe6d7cd 100644 --- a/packages/core/src/components/control-bar/control-bar.ts +++ b/packages/core/src/components/control-bar/control-bar.ts @@ -1,7 +1,13 @@ import { LitElement, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { controlBarStyles } from './control-bar.styles'; -import type { ExportOptionsMap, PersistedExportOptions } from '@protspace/utils'; +import type { + ExportOptionsMap, + PersistedExportOptions, + FigurePresetId, + LegendPlacement, +} from '@protspace/utils'; +import { LEGEND_VALUES, maxLegendItemsForLayout } from '@protspace/utils'; import type { DataChangeDetail, ProtspaceData, @@ -9,14 +15,11 @@ import type { DataLoaderElement, StructureViewerElement, } from './types'; -import { LEGEND_VALUES } from '@protspace/utils'; import { toInternalValue } from '../legend/config'; import { handleDropdownEscape, isAnyDropdownOpen } from '../../utils/dropdown-helpers'; import { EXPORT_DEFAULTS, createDefaultExportOptions, - calculateHeightFromWidth, - calculateWidthFromHeight, isProjection3D, getProjectionPlane, toggleProteinSelection, @@ -80,11 +83,8 @@ export class ProtspaceControlBar extends LitElement { // Export configuration state @state() private exportFormat: 'png' | 'pdf' | 'ids' | 'parquet' = EXPORT_DEFAULTS.FORMAT; - @state() private exportImageWidth: number = EXPORT_DEFAULTS.IMAGE_WIDTH; - @state() private exportImageHeight: number = EXPORT_DEFAULTS.IMAGE_HEIGHT; - @state() private exportLegendWidthPercent: number = EXPORT_DEFAULTS.LEGEND_WIDTH_PERCENT; - @state() private exportLegendFontSizePx: number = EXPORT_DEFAULTS.LEGEND_FONT_SIZE_PX; - @state() private exportLockAspectRatio: boolean = EXPORT_DEFAULTS.LOCK_ASPECT_RATIO; + @state() private exportPublicationPreset: FigurePresetId = EXPORT_DEFAULTS.PUBLICATION_PRESET; + @state() private exportLegendPlacement: LegendPlacement = EXPORT_DEFAULTS.LEGEND_PLACEMENT; @state() private exportIncludeLegendSettings: boolean = true; @state() private exportIncludeExportOptions: boolean = true; private _scatterplotElement: ScatterplotElementLike | null = null; @@ -402,16 +402,22 @@ export class ProtspaceControlBar extends LitElement { } private handleExport() { + const base = { + type: this.exportFormat, + includeLegendSettings: this.exportIncludeLegendSettings, + includeExportOptions: this.exportIncludeExportOptions, + }; + const detail = + this.exportFormat === 'png' || this.exportFormat === 'pdf' + ? { + ...base, + mode: 'publication' as const, + presetId: this.exportPublicationPreset, + legendPlacement: this.exportLegendPlacement, + } + : base; const customEvent = new CustomEvent('export', { - detail: { - type: this.exportFormat, - imageWidth: this.exportImageWidth, - imageHeight: this.exportImageHeight, - legendWidthPercent: this.exportLegendWidthPercent, - legendFontSizePx: this.exportLegendFontSizePx, - includeLegendSettings: this.exportIncludeLegendSettings, - includeExportOptions: this.exportIncludeExportOptions, - }, + detail, bubbles: true, composed: true, }); @@ -487,61 +493,19 @@ export class ProtspaceControlBar extends LitElement { }); } - private handleWidthChange(newWidth: number) { - this._applyUserExportSettingsChange(() => { - const oldWidth = this.exportImageWidth; - this.exportImageWidth = newWidth; - - if (this.exportLockAspectRatio) { - this.exportImageHeight = calculateHeightFromWidth( - newWidth, - oldWidth, - this.exportImageHeight, - ); - } - }); - } - - private handleHeightChange(newHeight: number) { - this._applyUserExportSettingsChange(() => { - const oldHeight = this.exportImageHeight; - this.exportImageHeight = newHeight; - - if (this.exportLockAspectRatio) { - this.exportImageWidth = calculateWidthFromHeight( - newHeight, - oldHeight, - this.exportImageWidth, - ); - } - }); + private _visibleLegendExportItemCount(): number { + const el = document.querySelector('protspace-legend') as { + getLegendExportData?: () => { items: readonly { isVisible: boolean }[] }; + } | null; + if (!el?.getLegendExportData) return 0; + const d = el.getLegendExportData(); + return d.items.filter((i) => i.isVisible).length; } - /** - * Clamp a value to a range - */ - private clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); - } - - /** - * Handle number input blur - clamp value to range and update state - */ - private handleNumberInputBlur( - e: Event, - min: number, - max: number, - setter: (value: number) => void, - ): void { - const input = e.target as HTMLInputElement; - const value = parseInt(input.value); - if (isNaN(value) || value < min) { - setter(min); - } else if (value > max) { - setter(max); - } else { - setter(value); - } + private _legendExceedsPublicationCap(): boolean { + const n = this._visibleLegendExportItemCount(); + if (n === 0) return false; + return n > maxLegendItemsForLayout(this.exportPublicationPreset, this.exportLegendPlacement); } public getAllPersistedExportOptions(): ExportOptionsMap { @@ -568,24 +532,24 @@ export class ProtspaceControlBar extends LitElement { private _getCurrentExportSettings(): PersistedExportOptions { return { - imageWidth: this.exportImageWidth, - imageHeight: this.exportImageHeight, - lockAspectRatio: this.exportLockAspectRatio, - legendWidthPercent: this.exportLegendWidthPercent, - legendFontSizePx: this.exportLegendFontSizePx, + imageWidth: EXPORT_DEFAULTS.LEGACY_IMAGE_WIDTH, + imageHeight: EXPORT_DEFAULTS.LEGACY_IMAGE_HEIGHT, + lockAspectRatio: true, + legendWidthPercent: EXPORT_DEFAULTS.LEGACY_LEGEND_WIDTH_PERCENT, + legendFontSizePx: EXPORT_DEFAULTS.LEGACY_LEGEND_FONT_SIZE_PX, includeLegendSettings: this.exportIncludeLegendSettings, includeExportOptions: this.exportIncludeExportOptions, + publicationPresetId: this.exportPublicationPreset, + legendPlacement: this.exportLegendPlacement, }; } private _applyPersistedExportSettings(settings: PersistedExportOptions): void { - this.exportImageWidth = settings.imageWidth; - this.exportImageHeight = settings.imageHeight; - this.exportLockAspectRatio = settings.lockAspectRatio; - this.exportLegendWidthPercent = settings.legendWidthPercent; - this.exportLegendFontSizePx = settings.legendFontSizePx; this.exportIncludeLegendSettings = settings.includeLegendSettings; this.exportIncludeExportOptions = settings.includeExportOptions; + this.exportPublicationPreset = + settings.publicationPresetId ?? EXPORT_DEFAULTS.PUBLICATION_PRESET; + this.exportLegendPlacement = settings.legendPlacement ?? EXPORT_DEFAULTS.LEGEND_PLACEMENT; } private _applyUserExportSettingsChange(update: () => void): void { @@ -1100,7 +1064,7 @@ export class ProtspaceControlBar extends LitElement { Include export options settings
- Legend customizations and remembered export dimensions can be + Legend customizations and remembered figure export settings can be saved in the file and restored when loading.
@@ -1110,236 +1074,99 @@ export class ProtspaceControlBar extends LitElement { ${this.exportFormat === 'png' || this.exportFormat === 'pdf' ? html` -
-
- - { - this.handleWidthChange( - parseInt((e.target as HTMLInputElement).value), - ); +
+ +
+
- -
- - { - this.handleHeightChange( - parseInt((e.target as HTMLInputElement).value), - ); + > + 1 col (88×70 mm) + +
- -
- -
- - { - this._applyUserExportSettingsChange(() => { - this.exportLegendWidthPercent = parseInt( - (e.target as HTMLInputElement).value, - ); - }); - }} - /> -
- 15% - 50% + > + Full (180×140 mm) +
-
- - { - this._applyUserExportSettingsChange(() => { - this.exportLegendFontSizePx = parseInt( - (e.target as HTMLInputElement).value, - ); - }); - }} - /> -
- ${EXPORT_DEFAULTS.MIN_LEGEND_FONT_SIZE_PX}px - ${EXPORT_DEFAULTS.MAX_LEGEND_FONT_SIZE_PX}px + +
+ +
- + ${this._legendExceedsPublicationCap() + ? html` +
+ Legend lists up to + ${maxLegendItemsForLayout( + this.exportPublicationPreset, + this.exportLegendPlacement, + )} + categories; additional ones appear as a summary line. Use + Parquet or Protein IDs for the full list. +
+ ` + : ''}
- +
- - ${this.showExportMenu - ? html` -
-
- Export Options -
- -
- -
- -
- - - - -
-
- - - ${this.exportFormat === 'parquet' - ? html` -
- - -
- Legend customizations and remembered figure export settings can be - saved in the file and restored when loading. -
-
- ` - : ''} - - - ${this.exportFormat === 'png' || this.exportFormat === 'pdf' - ? html` -
- - -
- ${this._legendExceedsPublicationCap() - ? html` -
- Legend lists up to - ${maxLegendItemsForLayout(this.exportLayoutId)} categories; - additional ones appear as a summary line. Use Parquet or - Protein IDs for the full list. -
- ` - : ''} -
- - -
- ` - : html` - - `} -
-
- ` - : ''}
+
${this._renderModeSelector()} ${this._renderLayoutControls()} @@ -318,8 +319,7 @@ export class ProtspaceExportStudio extends LitElement { private _renderModeSelector() { const modes: { id: LayoutMode; label: string }[] = [ { id: 'publication', label: 'Publication' }, - { id: 'native', label: 'Native' }, - { id: 'freeform', label: 'Freeform' }, + { id: 'native', label: 'Screen' }, ]; return html` @@ -345,10 +345,7 @@ export class ProtspaceExportStudio extends LitElement { if (this._layoutMode === 'publication') { return this._renderPublicationPresets(); } - if (this._layoutMode === 'native') { - return this._renderNativeInfo(); - } - return this._renderFreeformInputs(); + return this._renderNativeInfo(); } private _renderPublicationPresets() { @@ -383,9 +380,51 @@ export class ProtspaceExportStudio extends LitElement { ${FIGURE_LAYOUTS[this._layoutId].legend.columns}
- Band - ${FIGURE_LAYOUTS[this._layoutId].legendBandMm}mm +
+ ${!this._legendLocked + ? html` +
+ Font size + ${this._legendFontSizePx}px +
+ +
+ Legend width + ${this._legendWidthPercent}% +
+ + ` + : nothing}
`; } @@ -409,64 +448,6 @@ export class ProtspaceExportStudio extends LitElement { `; } - private _applyFreeformPreset(preset: FreeformPreset) { - this._freeformWidth = preset.width; - this._freeformHeight = preset.height; - } - - private _renderFreeformInputs() { - return html` -
-

Custom Dimensions

-
- ${FREEFORM_PRESETS.map( - (p) => html` - - `, - )} -
-
- - -
-
- - -
-

Exports scatter at custom pixel dimensions (no legend).

-
- `; - } - private _renderIndicatorsSection() { return html`
@@ -528,6 +509,16 @@ export class ProtspaceExportStudio extends LitElement { `; } + private _dispatchExportAction(type: string) { + this.dispatchEvent( + new CustomEvent('export-action', { + detail: { type }, + bubbles: true, + composed: true, + }), + ); + } + private _renderDownloadButtons() { const disabled = !this.scatterCapture || !this.legendModel; return html` @@ -537,7 +528,7 @@ export class ProtspaceExportStudio extends LitElement { ?disabled="${disabled}" @click="${() => this._handleDownload('png')}" > - Download PNG + PNG + +
`; } diff --git a/packages/core/src/components/scatter-plot/context-menu.styles.ts b/packages/core/src/components/scatter-plot/context-menu.styles.ts index 39a59475..f39f7f56 100644 --- a/packages/core/src/components/scatter-plot/context-menu.styles.ts +++ b/packages/core/src/components/scatter-plot/context-menu.styles.ts @@ -4,18 +4,19 @@ import { css } from 'lit'; export const contextMenuStyles = css` :host { position: absolute; - z-index: 200; + z-index: var(--z-dropdown, 100); pointer-events: auto; } .menu { - background: var(--surface-overlay, #1e1e2e); - border: 1px solid var(--border-color, #444); - border-radius: 8px; + position: absolute; + background: var(--surface, #fff); + border: var(--border-width, 1px) solid var(--border, #e2e8f0); + border-radius: var(--radius, 6px); padding: 4px 0; min-width: 180px; - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); - font-size: 12px; + box-shadow: var(--shadow-lg, 0 8px 30px rgba(0, 0, 0, 0.12)); + font-size: var(--text-base, 12px); font-family: inherit; } @@ -24,7 +25,7 @@ export const contextMenuStyles = css` align-items: center; gap: 10px; padding: 7px 14px; - color: var(--text-primary, #ddd); + color: var(--text-primary, #334155); cursor: pointer; border: none; background: none; @@ -34,11 +35,12 @@ export const contextMenuStyles = css` } .menu-item:hover { - background: var(--surface-hover, #2a3a5a); + background: var(--primary-light, #eef6fb); + color: var(--primary, #00a3e0); } .menu-item[aria-disabled='true'] { - color: var(--text-disabled, #555); + color: var(--text-tertiary, #a0aec0); cursor: default; pointer-events: none; } @@ -52,13 +54,13 @@ export const contextMenuStyles = css` .menu-item .shortcut { margin-left: auto; - font-size: 10px; - color: var(--text-secondary, #667); + font-size: var(--text-xs, 10px); + color: var(--text-secondary, #5b6b7a); } .separator { height: 1px; - background: var(--border-color, #333); + background: var(--border, #e2e8f0); margin: 4px 0; } `; diff --git a/packages/core/src/components/scatter-plot/context-menu.ts b/packages/core/src/components/scatter-plot/context-menu.ts index ad35f460..592e6986 100644 --- a/packages/core/src/components/scatter-plot/context-menu.ts +++ b/packages/core/src/components/scatter-plot/context-menu.ts @@ -62,13 +62,14 @@ export function resolveMenuItems(hit: PointHit | null): MenuItem[] { export class ProtspaceContextMenu extends LitElement { static styles = contextMenuStyles; - @property({ type: Number }) x = 0; - @property({ type: Number }) y = 0; @property({ type: Boolean }) open = false; @property({ type: Array }) items: MenuItem[] = []; private _onClickOutside = (e: MouseEvent) => { - if (!this.contains(e.target as Node)) { + // Use composedPath() to correctly detect clicks inside nested shadow DOMs. + // With nested shadow DOM, e.target at the document level is retargeted to + // the outermost shadow host, so this.contains(e.target) always returns false. + if (!e.composedPath().includes(this)) { this._close(); } }; @@ -112,7 +113,7 @@ export class ProtspaceContextMenu extends LitElement { if (!this.open) return nothing; return html` - ` : ''} +
`; @@ -2452,7 +2488,12 @@ export class ProtspaceScatterplot extends LitElement { public captureAtResolution( width: number, height: number, - options: { dpr?: number; backgroundColor?: string; desaturateUnselected?: boolean } = {}, + options: { + dpr?: number; + backgroundColor?: string; + desaturateUnselected?: boolean; + skipAnnotations?: boolean; + } = {}, ): HTMLCanvasElement { if (!this._webglRenderer) { throw new Error('WebGL renderer not initialized'); @@ -2462,7 +2503,12 @@ export class ProtspaceScatterplot extends LitElement { throw new Error('Width and height must be positive numbers'); } - const { dpr = 1, backgroundColor = '#ffffff', desaturateUnselected = false } = options; + const { + dpr = 1, + backgroundColor = '#ffffff', + desaturateUnselected = false, + skipAnnotations = false, + } = options; const desatFactor = desaturateUnselected ? 0.75 : 0; // Capture WebGL content using native off-screen rendering @@ -2489,20 +2535,136 @@ export class ProtspaceScatterplot extends LitElement { } // Apply background color if the canvas has transparency + const outputCanvas = document.createElement('canvas'); + outputCanvas.width = webglCanvas.width; + outputCanvas.height = webglCanvas.height; + const ctx = outputCanvas.getContext('2d'); + if (!ctx) return webglCanvas; + if (backgroundColor && backgroundColor !== 'transparent') { - const outputCanvas = document.createElement('canvas'); - outputCanvas.width = webglCanvas.width; - outputCanvas.height = webglCanvas.height; - const ctx = outputCanvas.getContext('2d'); - if (ctx) { - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, outputCanvas.width, outputCanvas.height); - ctx.drawImage(webglCanvas, 0, 0); - return outputCanvas; - } + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, outputCanvas.width, outputCanvas.height); } + ctx.drawImage(webglCanvas, 0, 0); - return webglCanvas; + // Draw indicators and insets onto the export canvas + if (!skipAnnotations) { + this._drawAnnotationsOnCanvas(ctx, outputCanvas.width, outputCanvas.height); + } + + return outputCanvas; + } + + /** + * Draw indicator arrows and inset boxes onto a 2D canvas context. + * Uses the same data-space coordinates as the live DOM overlays, + * but renders them as canvas primitives for export. + */ + private _drawAnnotationsOnCanvas( + ctx: CanvasRenderingContext2D, + canvasW: number, + canvasH: number, + ): void { + if (!this._scales) return; + + // Compute scale factors: export canvas vs current viewport + const viewW = this.clientWidth || 1; + const viewH = this.clientHeight || 1; + const sx = canvasW / viewW; + const sy = canvasH / viewH; + + const scaleX = this._scales.x; + const scaleY = this._scales.y; + const transform = this._transform; + + // ── Draw indicators ── + for (const ind of this.indicators) { + const rawX = scaleX(ind.dataCoords[0]); + const rawY = scaleY(ind.dataCoords[1]); + const tipX = (rawX * transform.k + transform.x) * sx; + const tipY = (rawY * transform.k + transform.y) * sy; + const shaftX = tipX + ind.offsetPx[0] * sx; + const shaftY = tipY + ind.offsetPx[1] * sy; + + const shaftLen = 32 * sy; + const shaftTop = shaftY - shaftLen; + + // Arrow shaft + ctx.strokeStyle = '#111'; + ctx.lineWidth = Math.max(2 * sx, 1); + ctx.beginPath(); + ctx.moveTo(shaftX, shaftTop); + ctx.lineTo(tipX, tipY); + ctx.stroke(); + + // Arrow head + const headH = 9 * sy; + const headW = 6 * sx; + ctx.fillStyle = '#111'; + ctx.beginPath(); + ctx.moveTo(tipX, tipY); + ctx.lineTo(tipX - headW, tipY - headH); + ctx.lineTo(tipX + headW, tipY - headH); + ctx.closePath(); + ctx.fill(); + + // Label + const fontSize = Math.round(10 * sy); + ctx.font = `600 ${fontSize}px system-ui, sans-serif`; + const labelW = ctx.measureText(ind.label).width; + const labelPadX = 6 * sx; + const labelPadY = 2 * sy; + const labelX = shaftX - labelW / 2 - labelPadX; + const labelY = shaftTop - 4 * sy - fontSize - labelPadY; + + ctx.fillStyle = 'rgba(255,255,255,0.9)'; + ctx.beginPath(); + ctx.roundRect(labelX, labelY, labelW + labelPadX * 2, fontSize + labelPadY * 2, 3 * sx); + ctx.fill(); + ctx.fillStyle = '#111'; + ctx.fillText(ind.label, labelX + labelPadX, labelY + labelPadY + fontSize * 0.85); + } + + // ── Draw insets ── + // Inset position/size are 0-1 normalized to the container, so multiply by canvas dimensions + for (const inset of this.insets) { + const ix = inset.position.x * canvasW; + const iy = inset.position.y * canvasH; + const iw = inset.size.width * canvasW; + const ih = inset.size.height * canvasH; + + // Inset border + ctx.strokeStyle = '#333'; + ctx.lineWidth = Math.max(2.5 * sx, 1); + + if (inset.shape === 'circle') { + ctx.beginPath(); + ctx.ellipse(ix + iw / 2, iy + ih / 2, iw / 2, ih / 2, 0, 0, Math.PI * 2); + ctx.stroke(); + } else { + ctx.strokeRect(ix, iy, iw, ih); + } + + // Draw captured content if available + if (inset.capturedCanvas) { + ctx.save(); + if (inset.shape === 'circle') { + ctx.beginPath(); + ctx.ellipse(ix + iw / 2, iy + ih / 2, iw / 2, ih / 2, 0, 0, Math.PI * 2); + ctx.clip(); + } + ctx.drawImage(inset.capturedCanvas, ix, iy, iw, ih); + ctx.restore(); + } + + // Inset label + if (inset.label) { + const fontSize = Math.round(9 * sx); + ctx.font = `600 ${fontSize}px system-ui, sans-serif`; + ctx.fillStyle = '#333'; + ctx.fillText(inset.label, ix, iy + ih + fontSize + 4 * sx); + } + } } } diff --git a/packages/utils/src/visualization/export-utils.test.ts b/packages/utils/src/visualization/export-utils.test.ts index 38b8b567..2bc09116 100644 --- a/packages/utils/src/visualization/export-utils.test.ts +++ b/packages/utils/src/visualization/export-utils.test.ts @@ -1,7 +1,7 @@ /** @vitest-environment jsdom */ import { describe, it, expect, vi } from 'vitest'; -import { createExporter, ProtSpaceExporter, generateProtspaceExportBasename } from './export-utils'; +import { exportProteinIdsFromElement, generateProtspaceExportBasename } from './export-utils'; import type { ExportableElement, ExportableData } from './export-utils'; import { LEGEND_VALUES } from './shapes'; import { validateCanvasDimensions } from './canvas-limits'; @@ -26,14 +26,6 @@ function createMockElement(overrides: Partial = {}): Exportab } as ExportableElement; } -describe('createExporter', () => { - it('creates an exporter instance', () => { - const mockElement = createMockElement(); - const exporter = createExporter(mockElement); - expect(exporter).toBeInstanceOf(ProtSpaceExporter); - }); -}); - describe('validateCanvasDimensions', () => { it('accepts moderate dimensions', () => { expect(validateCanvasDimensions(2000, 1000).isValid).toBe(true); @@ -59,7 +51,7 @@ describe('generateProtspaceExportBasename', () => { }); }); -describe('exportProteinIds', () => { +describe('exportProteinIdsFromElement', () => { const baseData: ExportableData = { protein_ids: ['P1', 'P2', 'P3', 'P4'], annotations: { @@ -85,7 +77,7 @@ describe('exportProteinIds', () => { } return node; }); - createExporter(el).exportProteinIds(); + exportProteinIdsFromElement(el); vi.mocked(document.createElement).mockRestore(); const encoded = capturedHref.replace('data:text/plain;charset=utf-8,', ''); expect(decodeURIComponent(encoded).split('\n')).toEqual(['P1', 'P2', 'P3', 'P4']); @@ -108,7 +100,7 @@ describe('exportProteinIds', () => { } return node; }); - createExporter(el).exportProteinIds(); + exportProteinIdsFromElement(el); vi.mocked(document.createElement).mockRestore(); const encoded = capturedHref.replace('data:text/plain;charset=utf-8,', ''); expect(decodeURIComponent(encoded).split('\n')).toEqual(['P1', 'P2', 'P4']); diff --git a/packages/utils/src/visualization/export-utils.ts b/packages/utils/src/visualization/export-utils.ts index 33959d93..dfbfe43c 100644 --- a/packages/utils/src/visualization/export-utils.ts +++ b/packages/utils/src/visualization/export-utils.ts @@ -25,10 +25,6 @@ export interface ExportableElement extends Element { hiddenAnnotationValues?: string[]; } -export interface ExportOptions { - exportName?: string; -} - export function generateProtspaceExportBasename( element: Pick< ExportableElement, @@ -89,32 +85,3 @@ export function exportProteinIdsFromElement(element: ExportableElement): void { link.download = fileName; link.click(); } - -export class ProtSpaceExporter { - private element: ExportableElement; - - constructor(element: ExportableElement, _selectedProteins: string[] = []) { - this.element = element; - } - - exportProteinIds(_options: ExportOptions = {}): void { - exportProteinIdsFromElement(this.element); - } -} - -export function createExporter( - element: ExportableElement, - selectedProteins: string[] = [], -): ProtSpaceExporter { - return new ProtSpaceExporter(element, selectedProteins); -} - -export const exportUtils = { - exportProteinIds: ( - element: ExportableElement, - _selectedProteins?: string[], - _options?: ExportOptions, - ) => { - exportProteinIdsFromElement(element); - }, -}; diff --git a/packages/utils/src/visualization/publication-export/export-publication.ts b/packages/utils/src/visualization/publication-export/export-publication.ts index d3260717..b123ed0a 100644 --- a/packages/utils/src/visualization/publication-export/export-publication.ts +++ b/packages/utils/src/visualization/publication-export/export-publication.ts @@ -18,12 +18,18 @@ export interface PublicationExportRequest { scatterCapture: ScatterplotCaptureFn; legendModel: PublicationLegendModel; fileNameBase?: string; + /** Override legend band width (flexible legend mode) */ + legendBandMmOverride?: number; + /** Override legend font size in px (flexible legend mode) */ + fontSizeOverridePx?: number; } export async function exportPublicationFigure(req: PublicationExportRequest): Promise { const layoutDef = FIGURE_LAYOUTS[req.layoutId]; const dpi = req.dpi ?? PRINT_DPI_DEFAULT; - const layout = computePublicationLayout(layoutDef, req.viewportAspect); + const legendOverrides = + req.legendBandMmOverride != null ? { legendBandMm: req.legendBandMmOverride } : undefined; + const layout = computePublicationLayout(layoutDef, req.viewportAspect, legendOverrides); const bg = req.backgroundColor ?? '#ffffff'; const figW = Math.round(mmToPx(layout.figureMm.width, dpi)); @@ -42,6 +48,7 @@ export async function exportPublicationFigure(req: PublicationExportRequest): Pr drawPublicationLegend(ctx, rect, req.legendModel, { dpi, layoutId: req.layoutId, + fontSizeOverridePx: req.fontSizeOverridePx, }), dpi, backgroundColor: bg, diff --git a/packages/utils/src/visualization/publication-export/layout.ts b/packages/utils/src/visualization/publication-export/layout.ts index c02975bf..da8eb869 100644 --- a/packages/utils/src/visualization/publication-export/layout.ts +++ b/packages/utils/src/visualization/publication-export/layout.ts @@ -66,8 +66,10 @@ function placeLegendVertical( export function computePublicationLayout( layout: FigureLayout, viewportAspect?: number, + overrides?: { legendBandMm?: number }, ): PublicationLayout { - const { widthMm, heightMm, paddingMm, legendBandMm, legend } = layout; + const { widthMm, heightMm, paddingMm, legend } = layout; + const legendBandMm = overrides?.legendBandMm ?? layout.legendBandMm; const scatterAspect = viewportAspect ?? layout.scatterAspect; const innerX = paddingMm; const innerY = paddingMm; diff --git a/packages/utils/src/visualization/publication-export/legend-canvas.ts b/packages/utils/src/visualization/publication-export/legend-canvas.ts index 012d65d6..183637dd 100644 --- a/packages/utils/src/visualization/publication-export/legend-canvas.ts +++ b/packages/utils/src/visualization/publication-export/legend-canvas.ts @@ -120,9 +120,14 @@ export async function drawPublicationLegend( ctx: CanvasRenderingContext2D, rect: PxRect, model: PublicationLegendModel, - options: { dpi: number; layoutId: FigureLayoutId }, + options: { + dpi: number; + layoutId: FigureLayoutId; + /** Override font size in px (flexible legend mode). Bypasses auto-scaling. */ + fontSizeOverridePx?: number; + }, ): Promise { - const { dpi, layoutId } = options; + const { dpi, layoutId, fontSizeOverridePx } = options; const layout = FIGURE_LAYOUTS[layoutId]; const cols = layout.legend.columns; @@ -135,10 +140,19 @@ export async function drawPublicationLegend( const innerHmm = Math.max(4, legendBoxMmH - 2 * padMm - HEADER_BODY_MM); const displayedRows = visible.length + (hasFooter ? 1 : 0); - const bodyPt = legendBodyPt(innerHmm, displayedRows, HEADER_BODY_MM); - const headerPt = Math.min(10, bodyPt + 1); - const bodyPx = ptToPx(bodyPt, dpi) * BODY_FONT_SCALE; - const headerPx = ptToPx(headerPt, dpi); + let bodyPx: number; + let headerPx: number; + if (fontSizeOverridePx != null) { + // Flexible mode: use user-specified font size, scale to DPI + bodyPx = fontSizeOverridePx * (dpi / 96); + headerPx = bodyPx * 1.15; + } else { + // Locked mode: auto-compute from available space + const bodyPt = legendBodyPt(innerHmm, displayedRows, HEADER_BODY_MM); + const headerPt = Math.min(10, bodyPt + 1); + bodyPx = ptToPx(bodyPt, dpi) * BODY_FONT_SCALE; + headerPx = ptToPx(headerPt, dpi); + } const titleY = rect.y + paddingPx + headerPx / 2; const gridTop = rect.y + paddingPx + headerPx + Math.max(2, bodyPx * 0.4);