125 lines
8.3 KiB
TypeScript
125 lines
8.3 KiB
TypeScript
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 = `<circle class="ring-value" cx="60" cy="60" r="${RING_RADIUS}" style="stroke:var(${tone})"
|
|
stroke-dasharray="${(clamped * RING_CIRCUMFERENCE).toFixed(3)} ${RING_CIRCUMFERENCE.toFixed(3)}" transform="rotate(-90 60 60)"/>`;
|
|
// A positive share below 2 % keeps a narrow radial mark so it stays visible at the compact ring size.
|
|
const mark = clamped < 0.02 ? `<line class="ring-mark" x1="60" y1="${60 - RING_RADIUS - 6}" x2="60" y2="${60 - RING_RADIUS + 6}" style="stroke:var(${tone})"/>` : '';
|
|
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 `<figure class="donut-tile"><figcaption>${esc(title)}</figcaption>
|
|
<div class="ring"><svg viewBox="0 0 120 120" aria-hidden="true"><circle class="ring-track" cx="60" cy="60" r="${RING_RADIUS}"/>${ringArc(whole > 0 ? part / whole : 0, tone)}</svg>
|
|
<span class="ring-readout"><strong>${formatCount(part)}</strong><span>${formatShare(part, whole)}</span></span></div>
|
|
<p class="donut-basis">${esc(basis)}</p></figure>`;
|
|
}
|
|
|
|
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 = `<circle class="ring-value" cx="60" cy="60" r="${RING_RADIUS}" style="stroke:var(${item.tone})"
|
|
stroke-dasharray="${length.toFixed(3)} ${RING_CIRCUMFERENCE.toFixed(3)}" stroke-dashoffset="${(-start).toFixed(3)}" transform="rotate(-90 60 60)"><title>${esc(`${item.label}: ${formatCount(item.value)}`)}</title></circle>`;
|
|
start += length;
|
|
return arc;
|
|
}).join('') : '';
|
|
return `<figure class="donut-tile"><figcaption>${esc(title)}</figcaption>
|
|
<div class="ring"><svg viewBox="0 0 120 120" role="img" aria-label="${esc(segments.map(item => `${item.label} ${formatCount(item.value)}`).join(', '))}"><circle class="ring-track" cx="60" cy="60" r="${RING_RADIUS}"/>${arcs}</svg>
|
|
<span class="ring-readout"><strong>${formatCount(readout.value)}</strong><span>${esc(readout.share)}</span></span></div>
|
|
<ul class="donut-legend">${segments.filter(item => item.value > 0).map(item =>
|
|
`<li><i style="background:var(${item.tone})"></i>${esc(item.label)} <b>${formatCount(item.value)}</b></li>`).join('')}</ul></figure>`;
|
|
}
|
|
|
|
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 `<figure class="donut-tile${compact ? ' donut-tile--compact' : ''}"><figcaption>${esc(title)}</figcaption>
|
|
<div class="ring"><svg viewBox="0 0 120 120" aria-hidden="true"><circle class="ring-track" cx="60" cy="60" r="${RING_RADIUS}"/>${score === undefined ? '' : ringArc(score / 100, scoreTone(score))}</svg>
|
|
<span class="ring-readout"><strong>${label}</strong><span>${score === undefined ? 'kein Wert' : '/100'}</span></span></div>
|
|
${basis ? `<p class="donut-basis">${esc(basis)}</p>` : ''}</figure>`;
|
|
}
|
|
|
|
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 =>
|
|
`<span class="bar-seg" style="width:${(100 * item.value / Math.max(1, scale)).toFixed(3)}%;background:var(${item.tone})" title="${esc(`${item.label}: ${formatCount(item.value)}`)}"></span>`).join('');
|
|
return `<span class="bar" role="img" aria-label="${esc(segments.map(item => `${item.label} ${formatCount(item.value)}`).join(', '))}">${total ? parts : ''}</span>`;
|
|
}
|
|
|
|
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 `<path class="flow-link" d="M${leftX} ${y1.toFixed(1)} C${mid} ${y1.toFixed(1)} ${mid} ${y2.toFixed(1)} ${rightX} ${y2.toFixed(1)}"
|
|
style="stroke:var(${tone.get(link.from)});stroke-width:${stroke(link.value)}"><title>${esc(`${left.find(node => node.id === link.from)?.label} → ${right.find(node => node.id === link.to)?.label}: ${formatCount(link.value)}`)}</title></path>`;
|
|
}).join('');
|
|
const leftNodes = left.map(node => {
|
|
const y = leftY.get(node.id)!;
|
|
return `<rect class="flow-node" x="${leftX - 6}" y="${(y - 11).toFixed(1)}" width="6" height="22" style="fill:var(${node.tone ?? '--graph-category'})"/>
|
|
<text class="flow-label" x="${leftX - 14}" y="${(y + 5).toFixed(1)}" text-anchor="end">${esc(node.label)} <tspan class="flow-count">${formatCount(node.total)}</tspan></text>`;
|
|
}).join('');
|
|
const rightNodes = right.map(node => {
|
|
const y = rightY.get(node.id)!;
|
|
return `<rect class="flow-node" x="${rightX}" y="${(y - 9).toFixed(1)}" width="6" height="18" style="fill:var(${node.tone ?? '--context-marker'})"/>
|
|
<text class="flow-label" x="${rightX + 14}" y="${(y + 5).toFixed(1)}">${esc(node.label)} <tspan class="flow-count">${formatCount(node.total)}</tspan>${node.detail ? ` <tspan class="flow-detail">${esc(node.detail)}</tspan>` : ''}</text>`;
|
|
}).join('');
|
|
return `<svg class="flow" viewBox="0 0 ${width} ${height}" role="img" aria-label="Security Domains und die Problemfelder ihrer Abweichungen">${paths}${leftNodes}${rightNodes}</svg>`;
|
|
}
|