import { escapeHtml } from '../shared/html'; /** * Static SVG/HTML chart primitives of the Technical Report in the ASSESSOR chart language: full * 360-degree donuts with a neutral track, flat arc ends from 12 o'clock and a narrow mark for a * positive share below 2 %; flat bars without gradients or shadows. Colours are token references only. */ const esc = escapeHtml; const RING_RADIUS = 48; const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; export const formatCount = (value: number): string => value.toLocaleString('de-DE'); /** Whole percentage from the unrounded share; a positive share below 1 % reads `<1 %`. */ export function formatShare(part: number, whole: number): string { if (whole <= 0) return '—'; const share = 100 * part / whole; if (part > 0 && Math.round(share) === 0) return '<1 %'; return `${formatCount(Math.round(share))} %`; } function ringArc(share: number, tone: string): string { if (share <= 0) return ''; const clamped = Math.min(1, share); const arc = ``; // A positive share below 2 % keeps a narrow radial mark so it stays visible at the compact ring size. const mark = clamped < 0.02 ? `` : ''; return arc + mark; } /** Count/percentage donut: bold absolute count above the regular-weight share. */ export function countDonut(title: string, part: number, whole: number, tone: string, basis: string): string { return `
${esc(title)}
${formatCount(part)}${formatShare(part, whole)}

${esc(basis)}

`; } export interface DonutSegment { value: number; tone: string; label: string } /** * Segmented distribution donut: categorical arcs in the given order, clockwise from 12 o'clock on the * same track and stroke as every other donut, closing the ring when they sum to `whole`. The readout * shows the given count and share; a legend lists every segment with its count. */ export function segmentDonut(title: string, segments: DonutSegment[], whole: number, readout: { value: number; share: string }): string { let start = 0; const arcs = whole > 0 ? segments.filter(item => item.value > 0).map(item => { const length = item.value / whole * RING_CIRCUMFERENCE; const arc = `${esc(`${item.label}: ${formatCount(item.value)}`)}`; start += length; return arc; }).join('') : ''; return `
${esc(title)}
${arcs} ${formatCount(readout.value)}${esc(readout.share)}
    ${segments.filter(item => item.value > 0).map(item => `
  • ${esc(item.label)} ${formatCount(item.value)}
  • `).join('')}
`; } export function scoreTone(score?: number): string { return score === undefined ? '--donut-not-run' : score < 50 ? '--sev-critical' : score < 75 ? '--sev-medium' : '--donut-passed'; } /** Score ring on the 0-100 scale with its `/100` semantics instead of an invented count. */ export function scoreDonut(title: string, score: number | undefined, basis: string, compact = false): string { const label = score === undefined ? '—' : score > 0 && score < 0.5 ? '<1' : formatCount(Math.round(score)); return `
${esc(title)}
${label}${score === undefined ? 'kein Wert' : '/100'}
${basis ? `

${esc(basis)}

` : ''}
`; } export interface BarSegment { value: number; tone: string; label: string } /** One flat stacked bar; widths relative to `scale`, every segment labelled for assistive technology. */ export function stackedBar(segments: BarSegment[], scale: number): string { const total = segments.reduce((sum, item) => sum + item.value, 0); const parts = segments.filter(item => item.value > 0).map(item => ``).join(''); return `${total ? parts : ''}`; } export interface BipartiteNode { id: string; label: string; total: number; tone?: string; detail?: string } export interface BipartiteLink { from: string; to: string; value: number } /** * Pathfinder relation view as a static two-column diagram: Security Domains on the left, the Context * clusters their deviations belong to on the right. Link width follows the deviation count. */ export function bipartiteDiagram(left: BipartiteNode[], right: BipartiteNode[], links: BipartiteLink[]): string { const row = 24; const top = 12; const width = 700; const leftX = 230; const rightX = 410; const height = top * 2 + Math.max(left.length, right.length) * row; const leftGap = (height - top * 2) / Math.max(1, left.length); const leftY = new Map(left.map((node, index) => [node.id, top + leftGap * (index + 0.5)])); const rightY = new Map(right.map((node, index) => [node.id, top + row * (index + 0.5)])); const tone = new Map(left.map(node => [node.id, node.tone ?? '--graph-edge'])); const max = Math.max(1, ...links.map(link => link.value)); const stroke = (value: number) => (1.5 + 12 * Math.sqrt(value / max)).toFixed(2); const paths = links.filter(link => leftY.has(link.from) && rightY.has(link.to)).map(link => { const y1 = leftY.get(link.from)!; const y2 = rightY.get(link.to)!; const mid = (leftX + rightX) / 2; return `${esc(`${left.find(node => node.id === link.from)?.label} → ${right.find(node => node.id === link.to)?.label}: ${formatCount(link.value)}`)}`; }).join(''); const leftNodes = left.map(node => { const y = leftY.get(node.id)!; return ` ${esc(node.label)} ${formatCount(node.total)}`; }).join(''); const rightNodes = right.map(node => { const y = rightY.get(node.id)!; return ` ${esc(node.label)} ${formatCount(node.total)}${node.detail ? ` ${esc(node.detail)}` : ''}`; }).join(''); return `${paths}${leftNodes}${rightNodes}`; }