2024-02-21 08:32:04 +01:00
|
|
|
import { children, tag, px } from "./svg";
|
2024-02-21 05:01:46 +01:00
|
|
|
import * as L from "./layout";
|
|
|
|
import * as V from "./vec2";
|
2023-10-22 16:10:53 +02:00
|
|
|
import {
|
2024-02-21 08:32:04 +01:00
|
|
|
ChordConfig,
|
2023-10-22 16:10:53 +02:00
|
|
|
KeyboardKey,
|
|
|
|
KeySymbol,
|
|
|
|
Layout,
|
|
|
|
LayoutColorscheme,
|
2024-02-21 05:01:46 +01:00
|
|
|
LayoutMeasurements,
|
2023-10-22 16:10:53 +02:00
|
|
|
SpecialSymbols,
|
|
|
|
VisualKey,
|
|
|
|
} from "./types";
|
|
|
|
|
|
|
|
function textContents(input: KeySymbol): string {
|
|
|
|
if (input === SpecialSymbols.TL || input === SpecialSymbols.TR) return "■";
|
|
|
|
|
|
|
|
return input;
|
|
|
|
}
|
|
|
|
|
2024-02-21 08:32:04 +01:00
|
|
|
interface KeyRenderingFlags {
|
|
|
|
stroke: boolean;
|
|
|
|
text: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
function applyKeyPadding(
|
|
|
|
key: VisualKey,
|
|
|
|
measurements: LayoutMeasurements,
|
|
|
|
): { position: V.Vec2; size: V.Vec2 } {
|
|
|
|
return {
|
|
|
|
position: V.add(key.position, measurements.keyPadding),
|
|
|
|
size: V.add(key.size, -2 * measurements.keyPadding),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function keyCenter(key: VisualKey): V.Vec2 {
|
|
|
|
const centerX = key.position[0] + key.size[0] / 2;
|
|
|
|
const centerY = key.position[1] + key.size[1] / 2;
|
|
|
|
|
|
|
|
return [centerX, centerY];
|
|
|
|
}
|
|
|
|
|
|
|
|
const textAttribs = {
|
|
|
|
"text-anchor": "middle",
|
|
|
|
"dominant-baseline": "central",
|
|
|
|
"font-family": "Helvetica",
|
|
|
|
};
|
|
|
|
|
|
|
|
function textColor(
|
|
|
|
colorscheme: LayoutColorscheme,
|
|
|
|
input: KeySymbol,
|
|
|
|
_default: string,
|
|
|
|
): string {
|
|
|
|
if (input === SpecialSymbols.TL) return colorscheme.tlLayerColor;
|
|
|
|
if (input === SpecialSymbols.TR) return colorscheme.trLayerColor;
|
|
|
|
return _default;
|
|
|
|
}
|
|
|
|
|
2023-10-22 16:10:53 +02:00
|
|
|
function renderKey(
|
|
|
|
visual: VisualKey,
|
|
|
|
key: KeyboardKey,
|
|
|
|
colorscheme: LayoutColorscheme,
|
2024-02-21 05:01:46 +01:00
|
|
|
measurements: LayoutMeasurements,
|
2024-02-21 08:32:04 +01:00
|
|
|
flags: KeyRenderingFlags,
|
2023-10-22 16:10:53 +02:00
|
|
|
) {
|
2024-02-21 08:32:04 +01:00
|
|
|
const withPadding = applyKeyPadding(visual, measurements);
|
|
|
|
const center = keyCenter(visual);
|
2023-10-22 16:10:53 +02:00
|
|
|
|
2024-02-21 08:32:04 +01:00
|
|
|
const textOverlays = flags.text
|
|
|
|
? [
|
|
|
|
tag(
|
|
|
|
"text",
|
|
|
|
{
|
|
|
|
x: center[0],
|
|
|
|
y: center[1],
|
|
|
|
textLength: px(withPadding.size[1] / 2),
|
|
|
|
fill: textColor(colorscheme, key.main, colorscheme.mainLayerColor),
|
|
|
|
...textAttribs,
|
|
|
|
},
|
|
|
|
textContents(key.main),
|
|
|
|
),
|
|
|
|
tag(
|
|
|
|
"text",
|
|
|
|
{
|
|
|
|
x: withPadding.position[0] + withPadding.size[0] / 6,
|
|
|
|
y: withPadding.position[1] + withPadding.size[1] / 6,
|
|
|
|
fill: textColor(colorscheme, key.tlLayer, colorscheme.tlLayerColor),
|
|
|
|
"font-size": "66%",
|
|
|
|
...textAttribs,
|
|
|
|
"text-anchor": "start",
|
|
|
|
},
|
|
|
|
textContents(key.tlLayer),
|
|
|
|
),
|
|
|
|
tag(
|
|
|
|
"text",
|
|
|
|
{
|
|
|
|
x: withPadding.position[0] + (9 * withPadding.size[0]) / 10,
|
|
|
|
y: withPadding.position[1] + withPadding.size[1] / 6,
|
|
|
|
fill: textColor(colorscheme, key.trLayer, colorscheme.trLayerColor),
|
|
|
|
"font-size": "66%",
|
|
|
|
...textAttribs,
|
|
|
|
"text-anchor": "end",
|
|
|
|
},
|
|
|
|
textContents(key.trLayer),
|
|
|
|
),
|
|
|
|
tag(
|
|
|
|
"text",
|
|
|
|
{
|
|
|
|
x: withPadding.position[0] + withPadding.size[0] / 10,
|
|
|
|
y: withPadding.position[1] + (5 * withPadding.size[1]) / 6,
|
|
|
|
fill: textColor(colorscheme, key.blLayer, colorscheme.blLayerColor),
|
|
|
|
"font-size": "66%",
|
|
|
|
...textAttribs,
|
|
|
|
"text-anchor": "start",
|
|
|
|
},
|
|
|
|
textContents(key.blLayer),
|
|
|
|
),
|
|
|
|
]
|
|
|
|
: [];
|
2023-10-22 16:10:53 +02:00
|
|
|
|
|
|
|
return tag(
|
|
|
|
"g",
|
|
|
|
{
|
|
|
|
transform:
|
|
|
|
visual.angle && visual.angle !== 0
|
2024-02-21 08:32:04 +01:00
|
|
|
? `rotate(${visual.angle}, ${center[0]}, ${center[1]})`
|
2024-02-21 05:01:46 +01:00
|
|
|
: "",
|
2023-10-22 16:10:53 +02:00
|
|
|
},
|
2024-02-21 08:32:04 +01:00
|
|
|
children(
|
2023-10-22 16:10:53 +02:00
|
|
|
tag("rect", {
|
2024-02-21 05:01:46 +01:00
|
|
|
width: px(withPadding.size[0]),
|
|
|
|
height: px(withPadding.size[1]),
|
|
|
|
x: withPadding.position[0],
|
|
|
|
y: withPadding.position[1],
|
|
|
|
rx: measurements.keyCornerRadius,
|
2023-10-22 16:10:53 +02:00
|
|
|
fill: colorscheme.keyFill,
|
|
|
|
stroke: colorscheme.keyStroke,
|
2024-02-21 08:32:04 +01:00
|
|
|
"stroke-width": px(flags.stroke ? measurements.keyStrokeWidth : 0),
|
|
|
|
}),
|
|
|
|
...textOverlays,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function keyCorners(
|
|
|
|
key: VisualKey,
|
|
|
|
measurements: LayoutMeasurements,
|
|
|
|
): V.Vec2[] {
|
|
|
|
const withPadding = applyKeyPadding(key, measurements);
|
|
|
|
return [
|
|
|
|
withPadding.position,
|
|
|
|
V.add(withPadding.position, [withPadding.size[0], 0]),
|
|
|
|
V.add(withPadding.position, withPadding.size),
|
|
|
|
V.add(withPadding.position, [0, withPadding.size[1]]),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderChordShape(
|
|
|
|
first: VisualKey,
|
|
|
|
second: VisualKey,
|
|
|
|
chord: ChordConfig,
|
|
|
|
measurements: LayoutMeasurements,
|
|
|
|
colorscheme: LayoutColorscheme,
|
|
|
|
) {
|
|
|
|
if (first.position[0] > second.position[0])
|
|
|
|
return renderChordShape(second, first, chord, measurements, colorscheme);
|
|
|
|
|
|
|
|
const multi = (...steps: string[]) => steps.join(" ");
|
|
|
|
const moveTo = (to: V.Vec2) => `M ${to.join(" ")}`;
|
|
|
|
const lineTo = (to: V.Vec2) => `L ${to.join(" ")}`;
|
|
|
|
|
|
|
|
const firstCorners = keyCorners(first, measurements);
|
|
|
|
const secondCorners = keyCorners(second, measurements);
|
|
|
|
const firstCenter = keyCenter(first);
|
|
|
|
const secondCenter = keyCenter(second);
|
|
|
|
const middle = V.scale(V.add(firstCenter, secondCenter), 0.5);
|
|
|
|
|
|
|
|
const halfPath = (b: V.Vec2, c: V.Vec2, d: V.Vec2) => {
|
|
|
|
if ((b[1] - c[1]) * (b[0] - d[0]) > 0) return multi(lineTo(b), lineTo(d));
|
|
|
|
else return multi(lineTo(c), lineTo(d));
|
|
|
|
};
|
|
|
|
|
|
|
|
const dottedIndicator = (key: VisualKey) => {
|
|
|
|
const withPadding = applyKeyPadding(key, measurements);
|
|
|
|
const center = keyCenter(key);
|
|
|
|
const radius = Math.min(...withPadding.size) / 7.5;
|
|
|
|
return tag("circle", {
|
|
|
|
cx: center[0],
|
|
|
|
cy: center[1],
|
|
|
|
r: radius,
|
|
|
|
fill: "gray",
|
|
|
|
stroke: "gray",
|
|
|
|
"fill-opacity": 0.1,
|
|
|
|
"stroke-opacity": 0.6,
|
|
|
|
"stroke-width": px(measurements.keyStrokeWidth),
|
|
|
|
"stroke-dasharray": (radius * 2 * Math.PI) / 12,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
return tag(
|
|
|
|
"g",
|
|
|
|
{},
|
|
|
|
children(
|
|
|
|
tag("path", {
|
|
|
|
fill: chord.fill,
|
|
|
|
stroke: chord.stroke,
|
2024-02-21 05:01:46 +01:00
|
|
|
"stroke-width": px(measurements.keyStrokeWidth),
|
2024-02-21 08:32:04 +01:00
|
|
|
d: multi(
|
|
|
|
moveTo(firstCorners[0]),
|
|
|
|
halfPath(firstCorners[1], secondCorners[0], secondCorners[1]),
|
|
|
|
lineTo(secondCorners[2]),
|
|
|
|
halfPath(secondCorners[3], firstCorners[2], firstCorners[3]),
|
|
|
|
"Z", // close path
|
|
|
|
),
|
2023-10-22 16:10:53 +02:00
|
|
|
}),
|
2024-02-21 08:32:04 +01:00
|
|
|
dottedIndicator(first),
|
|
|
|
dottedIndicator(second),
|
2023-10-22 16:10:53 +02:00
|
|
|
tag(
|
|
|
|
"text",
|
|
|
|
{
|
2024-02-21 08:32:04 +01:00
|
|
|
x: middle[0],
|
|
|
|
y: middle[1],
|
|
|
|
"font-size": `${(chord.fontSizeModifier || 1) * 70}%`,
|
|
|
|
fill: textColor(
|
|
|
|
colorscheme,
|
|
|
|
chord.output,
|
|
|
|
colorscheme.mainLayerColor,
|
|
|
|
),
|
2023-10-22 16:10:53 +02:00
|
|
|
...textAttribs,
|
2024-02-21 08:32:04 +01:00
|
|
|
"dominant-baseline": "middle",
|
2023-10-22 16:10:53 +02:00
|
|
|
},
|
2024-02-21 08:32:04 +01:00
|
|
|
textContents(chord.output),
|
2023-10-22 16:10:53 +02:00
|
|
|
),
|
2024-02-21 08:32:04 +01:00
|
|
|
),
|
2023-10-22 16:10:53 +02:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
export function renderLayout(layout: Layout) {
|
2024-02-21 08:32:04 +01:00
|
|
|
const totalWidth = layout.size[0] * layout.measurements.keySize;
|
|
|
|
const chordKeyScalingFactor =
|
|
|
|
(totalWidth / layout.elementLayout.groupsPerRow -
|
|
|
|
layout.elementLayout.groupPadding * 2) /
|
|
|
|
totalWidth;
|
|
|
|
|
|
|
|
const chordRowCount = Math.ceil(
|
|
|
|
layout.chords.length / layout.elementLayout.groupsPerRow,
|
|
|
|
);
|
|
|
|
|
|
|
|
const totalHeight =
|
|
|
|
layout.elementLayout.mainToChordsGap +
|
|
|
|
layout.measurements.keySize *
|
|
|
|
layout.size[1] *
|
|
|
|
(1 + chordRowCount / layout.elementLayout.groupsPerRow);
|
|
|
|
|
|
|
|
const widthPerChord = totalWidth / layout.elementLayout.groupsPerRow;
|
|
|
|
|
|
|
|
// {{{ Render main keys
|
|
|
|
const mainKeys = layout.visual.map((key, index) =>
|
|
|
|
renderKey(
|
|
|
|
L.scaleVisual(key, layout.measurements.keySize),
|
|
|
|
layout.keys[index],
|
|
|
|
layout.colorscheme,
|
|
|
|
layout.measurements,
|
|
|
|
{
|
|
|
|
text: true,
|
|
|
|
stroke: true,
|
|
|
|
},
|
|
|
|
),
|
|
|
|
);
|
|
|
|
// }}}
|
|
|
|
// {{{ Render chord groups
|
|
|
|
const chordMeasurements = L.scaleMeasurements(
|
|
|
|
layout.measurements,
|
|
|
|
chordKeyScalingFactor,
|
|
|
|
);
|
|
|
|
const chords = layout.chords.map((group, index) => {
|
|
|
|
const normalKeys = layout.visual.map((key, index) => {
|
|
|
|
const keyLabels = layout.keys[index];
|
|
|
|
|
|
|
|
if (group.findIndex((c) => c.input.includes(keyLabels.main)) !== -1)
|
|
|
|
return "";
|
|
|
|
|
|
|
|
return renderKey(
|
|
|
|
L.scaleVisual(key, chordMeasurements.keySize),
|
|
|
|
keyLabels,
|
|
|
|
{ ...layout.colorscheme, keyFill: "gray" },
|
|
|
|
chordMeasurements,
|
|
|
|
{
|
|
|
|
text: false,
|
|
|
|
stroke: false,
|
|
|
|
},
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
const chordShapes = group.map((chord) => {
|
|
|
|
return renderChordShape(
|
|
|
|
L.scaleVisual(
|
|
|
|
L.findKeyByLabel(layout, chord.input[0])!,
|
|
|
|
chordMeasurements.keySize,
|
|
|
|
),
|
|
|
|
L.scaleVisual(
|
|
|
|
L.findKeyByLabel(layout, chord.input[1])!,
|
|
|
|
chordMeasurements.keySize,
|
|
|
|
),
|
|
|
|
chord,
|
|
|
|
chordMeasurements,
|
|
|
|
layout.colorscheme,
|
|
|
|
);
|
|
|
|
});
|
|
|
|
|
|
|
|
return tag(
|
|
|
|
"g",
|
|
|
|
{
|
|
|
|
transform: `translate(${
|
|
|
|
(index % layout.elementLayout.groupsPerRow) * widthPerChord +
|
|
|
|
layout.elementLayout.groupPadding +
|
|
|
|
(index + layout.elementLayout.groupsPerRow > layout.chords.length
|
|
|
|
? ((layout.elementLayout.groupsPerRow -
|
|
|
|
(layout.chords.length % layout.elementLayout.groupsPerRow)) *
|
|
|
|
widthPerChord) /
|
|
|
|
2
|
|
|
|
: 0)
|
|
|
|
} ${
|
|
|
|
(Math.floor(index / layout.elementLayout.groupsPerRow) *
|
|
|
|
layout.measurements.keySize *
|
|
|
|
layout.size[1]) /
|
|
|
|
layout.elementLayout.groupsPerRow +
|
|
|
|
layout.elementLayout.groupPadding
|
|
|
|
})`,
|
|
|
|
},
|
|
|
|
children(...normalKeys, ...chordShapes),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
// }}}
|
|
|
|
// {{{ Put everything together
|
2023-10-22 16:10:53 +02:00
|
|
|
return tag(
|
|
|
|
"svg",
|
|
|
|
{
|
|
|
|
viewBox: [
|
2024-02-21 08:32:04 +01:00
|
|
|
-layout.elementLayout.imagePadding,
|
|
|
|
-layout.elementLayout.imagePadding,
|
|
|
|
2 * layout.elementLayout.imagePadding + totalWidth,
|
|
|
|
|
|
|
|
2 * layout.elementLayout.imagePadding + totalHeight,
|
2023-10-22 16:10:53 +02:00
|
|
|
].join(" "),
|
|
|
|
xmlns: "http://www.w3.org/2000/svg",
|
|
|
|
"xmlns:xlink": "http://www.w3.org/1999/xlink",
|
|
|
|
},
|
2024-02-21 08:32:04 +01:00
|
|
|
children(
|
|
|
|
...mainKeys,
|
|
|
|
tag(
|
|
|
|
"g",
|
|
|
|
{
|
|
|
|
transform: `translate(0 ${
|
|
|
|
layout.size[1] * layout.measurements.keySize +
|
|
|
|
layout.elementLayout.mainToChordsGap
|
|
|
|
})`,
|
|
|
|
},
|
|
|
|
children(...chords),
|
|
|
|
),
|
|
|
|
),
|
2023-10-22 16:10:53 +02:00
|
|
|
);
|
2024-02-21 08:32:04 +01:00
|
|
|
// }}}
|
2023-10-22 16:10:53 +02:00
|
|
|
}
|