e58f06d9fa
Stripped upstream git history; starting point for replacing the native SWELL/Win32 mapping UI with something more suited to bulk editing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
236 lines
7.8 KiB
JavaScript
236 lines
7.8 KiB
JavaScript
import {createSVGWindow} from "svgdom";
|
|
import svgjs from "@svgdotjs/svg.js";
|
|
|
|
const {SVG, registerWindow} = svgjs;
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import pako from "pako";
|
|
import got from "got";
|
|
import '@svgdotjs/svg.topath.js';
|
|
|
|
const script_dir = path.resolve('doc/svg-gen');
|
|
|
|
// We need to embed the CSS into the SVG, otherwise the browser won't load it, tried it.
|
|
// (see https://stackoverflow.com/questions/18434094/how-to-style-svg-with-external-css).
|
|
const stylesheet = fs.readFileSync(path.resolve(script_dir, 'styles.css'));
|
|
|
|
generate()
|
|
|
|
async function generate() {
|
|
fs.writeFileSync('doc/architecture/images/modules.svg', await generateModulesDiagram())
|
|
fs.writeFileSync('doc/architecture/images/onion-layers.svg', generateOnionLayersDiagram())
|
|
fs.writeFileSync('doc/architecture/images/components.svg', await generateComponentDiagram())
|
|
fs.writeFileSync('doc/architecture/images/components-midi-fx.svg', await generateComponentDiagram([
|
|
'role-realtime-general',
|
|
'role-realtime-midi-general',
|
|
'role-realtime-midi-fx'
|
|
]))
|
|
fs.writeFileSync('doc/architecture/images/components-midi-device.svg', await generateComponentDiagram([
|
|
'role-realtime-general',
|
|
'role-realtime-midi-general',
|
|
'role-realtime-midi-device'
|
|
]))
|
|
fs.writeFileSync('doc/architecture/images/components-osc.svg', await generateComponentDiagram([
|
|
'role-realtime-general',
|
|
'role-realtime-osc',
|
|
]))
|
|
fs.writeFileSync('doc/architecture/images/components-management.svg', await generateComponentDiagram([
|
|
'role-sync-data',
|
|
'role-notify',
|
|
]))
|
|
}
|
|
|
|
async function generateModulesDiagram() {
|
|
const draw = await loadDotToSvgCanvas('modules.dot');
|
|
return draw.svg();
|
|
}
|
|
|
|
async function generateComponentDiagram(shownEdgeClasses = undefined) {
|
|
const draw = await loadDotToSvgCanvas('components.dot');
|
|
if (shownEdgeClasses) {
|
|
draw.style('.edge', {visibility: 'hidden'})
|
|
for (let edgeClass of shownEdgeClasses) {
|
|
draw.style(`.edge.${edgeClass}`, {visibility: 'visible'})
|
|
}
|
|
}
|
|
return draw.svg();
|
|
}
|
|
|
|
function generateOnionLayersDiagram() {
|
|
const draw = initSvgCanvas();
|
|
const width = 410;
|
|
const height = 410;
|
|
draw.size(width, height);
|
|
embedStylesheet(draw);
|
|
|
|
// Default attributes
|
|
const defaultFontSize = 15;
|
|
const defaultArrowColor = 'black';
|
|
const defaultArrowWidth = 2;
|
|
const baseFont = {
|
|
size: defaultFontSize,
|
|
}
|
|
const arrowPattern = createArrowPattern('rule-arrow');
|
|
const defaultArrowHead = arrowHead();
|
|
|
|
// Layers
|
|
const infrastructureLayer = layer(4, 'layer-infrastructure', 'infrastructure', [
|
|
'GUI',
|
|
'API',
|
|
'Persistence',
|
|
'Server',
|
|
]);
|
|
layer(3, 'layer-management', 'management');
|
|
layer(2, 'layer-processing', 'processing');
|
|
const baseLayer = layer(1, 'layer-base', 'base');
|
|
|
|
// Arrows
|
|
drawArrow(infrastructureLayer.x(), infrastructureLayer.cy(), baseLayer.cx(), baseLayer.cy(), {
|
|
text: 'may use code in',
|
|
patternOrColor: arrowPattern,
|
|
width: 10,
|
|
drawHead: false,
|
|
cssClass: 'rule-arrow',
|
|
useClipping: true,
|
|
});
|
|
|
|
function arrowHead() {
|
|
return draw.marker(10, 7, (add) => {
|
|
add.polygon('0,0 10,3.5 0,7').addClass('arrow-head');
|
|
});
|
|
}
|
|
|
|
function arcPath(radius, cx, cy, sweep) {
|
|
return [
|
|
// Start at 9
|
|
['M', cx - radius, cy],
|
|
// Go to 3
|
|
['A', -radius, -radius, 0, 0, sweep, cx + radius, cy],
|
|
];
|
|
}
|
|
|
|
function layer(index, cssClass, label, components = []) {
|
|
const g = draw.group().addClass(cssClass);
|
|
const spacing = 50;
|
|
const radius = index * spacing;
|
|
const circle = g
|
|
.circle(radius * 2)
|
|
.center(width / 2, height / 2)
|
|
.fill('none')
|
|
.stroke({color: 'black'})
|
|
.addClass('layerpart-circle')
|
|
const pathRadius = radius - spacing / 2;
|
|
const radiusFix = defaultFontSize / 3;
|
|
const upperArc = arcPath(
|
|
pathRadius - radiusFix,
|
|
circle.cx(),
|
|
circle.cy(),
|
|
1
|
|
);
|
|
g.textPath(label, upperArc)
|
|
.attr('text-anchor', 'middle')
|
|
.attr('startOffset', '50%')
|
|
.font(baseFont)
|
|
.addClass('layerpart-label')
|
|
const lowerArc = arcPath(
|
|
pathRadius + radiusFix,
|
|
circle.cx(),
|
|
circle.cy(),
|
|
0
|
|
);
|
|
for (let i = 0; i < components.length; i++) {
|
|
const segmentLength = (1 / components.length) * 100;
|
|
const offset = i * segmentLength + segmentLength / 2;
|
|
g.textPath(components[i], lowerArc)
|
|
.attr('text-anchor', 'middle')
|
|
.attr('startOffset', `${offset}%`)
|
|
.font(baseFont)
|
|
.addClass('layerpart-secondary-label');
|
|
}
|
|
return circle;
|
|
}
|
|
|
|
function createArrowPattern(cssClass = undefined) {
|
|
return draw.pattern(30, 20, (add) => {
|
|
const g = add.group().addClass(cssClass);
|
|
g.line(0, 3.5, 10, 3.5)
|
|
.stroke({color: defaultArrowColor, width: 1})
|
|
.addClass('arrow-pattern-path');
|
|
g.polygon('10,0 20,3.5 10,7').addClass('arrow-pattern-head');
|
|
});
|
|
}
|
|
|
|
function drawArrow(x1, y1, x2, y2, {
|
|
patternOrColor = defaultArrowColor,
|
|
width = defaultArrowWidth,
|
|
head = defaultArrowHead,
|
|
text,
|
|
drawHead = true,
|
|
cssClass = undefined,
|
|
useClipping = false,
|
|
}) {
|
|
// Group for clipping
|
|
const outer_group = draw.group().addClass(cssClass);
|
|
const inner_group = outer_group.group();
|
|
// Arrow itself
|
|
const line = useClipping ? inner_group.line(x1 - 100, y1, x2 + 100, y2) : inner_group.line(x1, y1, x2, y2);
|
|
const path = line.toPath().addClass('arrow-path');
|
|
path.stroke({color: patternOrColor, width});
|
|
// Head
|
|
if (drawHead) {
|
|
path.marker('end', head.size(8, 8))
|
|
}
|
|
// Text
|
|
outer_group.textPath()
|
|
.plot(path.array())
|
|
.text(add => {
|
|
add.tspan(text).dy(-10)
|
|
})
|
|
.addClass('arrowpart-label')
|
|
.font({...baseFont, anchor: 'middle', startOffset: '50%'});
|
|
// Clip (useful for keeping CSS transform animation within bounds)
|
|
if (useClipping) {
|
|
const clip = inner_group.clip()
|
|
.add(inner_group.polygon().plot([[x1, y1 - width], [x2, y2 - width], [x2, y1 + width], [12, y2 + width]]));
|
|
inner_group.clipWith(clip);
|
|
}
|
|
return outer_group;
|
|
}
|
|
|
|
return draw.svg();
|
|
}
|
|
|
|
async function loadDotToSvgCanvas(dotFileName) {
|
|
const dot = fs.readFileSync(path.resolve(script_dir, dotFileName));
|
|
const svg = await convertDotToSvg(dot);
|
|
const qualifiedSvg = svg.replace(/<(!DOCTYPE[^>\[]+(\[[^\]]+)?)+[^>]+>/g, '');
|
|
const window = createSVGWindow()
|
|
const document = window.document
|
|
registerWindow(window, document)
|
|
const originalDraw = SVG(document.documentElement);
|
|
originalDraw.svg(qualifiedSvg, true);
|
|
const draw = SVG(document.documentElement);
|
|
embedStylesheet(draw);
|
|
return draw;
|
|
}
|
|
|
|
async function convertDotToSvg(dot) {
|
|
const data = Buffer.from(dot, 'utf8')
|
|
const compressed = pako.deflate(data, {level: 9})
|
|
const body = Buffer.from(compressed)
|
|
.toString('base64')
|
|
.replace(/\+/g, '-').replace(/\//g, '_')
|
|
const response = await got(`https://kroki.io/graphviz/svg/${body}`);
|
|
return response.body
|
|
}
|
|
|
|
function initSvgCanvas() {
|
|
const window = createSVGWindow()
|
|
const document = window.document
|
|
registerWindow(window, document)
|
|
return SVG(document.documentElement);
|
|
}
|
|
|
|
function embedStylesheet(draw) {
|
|
draw.element('style').words(stylesheet)
|
|
} |