1
Fork 0

Vizualise chords

This commit is contained in:
prescientmoon 2024-02-21 08:32:04 +01:00
parent 9b089af081
commit 39f0ec8f53
Signed by: prescientmoon
SSH key fingerprint: SHA256:UUF9JT2s8Xfyv76b8ZuVL7XrmimH4o49p4b+iexbVH4
12 changed files with 1977 additions and 766 deletions

View file

@ -0,0 +1,7 @@
# My laptop layout!
![layout preview](./lens.svg)
## Layout philosophy
This is pretty much a port of my [ferris sweep layout](../../qmk/ferris-sweep).

View file

@ -1,5 +1,11 @@
{
"layout": "alpha_staggered_double_switch",
"measurements": {
"keySize": 60,
"keyPadding": 2,
"keyCornerRadius": 5,
"keyStrokeWidth": 1.5
},
"keys": [
["Q", "!", "1", "f1"],
["A", "<", "6", "f6"],
@ -34,5 +40,88 @@
[":", "`", "🗑️", "🔆"],
["O", ";", "", "🔅"],
["'", "\"", ""]
],
"chords": [
[
{
"input": ["Q", "W"],
"output": "⎋",
"fill": "#9ccaff"
},
{
"input": ["A", "R"],
"output": "⌥",
"fill": "#39f785"
},
{
"input": ["S", "T"],
"output": "⭾",
"fill": "#fdff80",
"fontSizeModifier": 0.8
},
{
"input": ["G", "M"],
"output": "⌫",
"fill": "#f9adff"
},
{
"input": ["N", "E"],
"output": "⊥",
"fill": "#f58e8e"
},
{
"input": ["I", "O"],
"output": "⌥",
"fill": "#39f785"
}
],
[
{
"input": ["R", "S"],
"output": "⇧",
"fill": "#f9adff"
},
{
"input": ["E", "I"],
"output": "⇧",
"fill": "#f9adff"
}
],
[
{
"input": ["R", "T"],
"output": "ctrl",
"fill": "#fdff80"
},
{
"input": ["N", "I"],
"output": "ctrl",
"fill": "#fdff80"
}
],
[
{
"input": ["C", "P"],
"output": "📋",
"fill": "#fdff80"
},
{
"input": ["K", "I"],
"output": "❖",
"fill": "#f58e8e"
}
],
[
{
"input": ["F", "T"],
"output": "↵",
"fill": "#9ccaff"
},
{
"input": ["N", "U"],
"output": "💾",
"fill": "#9ccaff"
}
]
]
}

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View file

@ -1,5 +1,11 @@
{
"layout": "split_3x5_2",
"measurements": {
"keySize": 60,
"keyPadding": 2,
"keyCornerRadius": 5,
"keyStrokeWidth": 1.5
},
"keys": [
["Q", "!", "1", "f1"],
["A", "<", "6", "f6"],
@ -35,5 +41,88 @@
[":", "`", "🗑️", "🔆"],
["O", ";", "", "🔅"],
["'", "\"", ""]
],
"chords": [
[
{
"input": ["Q", "W"],
"output": "⎋",
"fill": "#9ccaff"
},
{
"input": ["A", "R"],
"output": "⌥",
"fill": "#39f785"
},
{
"input": ["S", "T"],
"output": "⭾",
"fill": "#fdff80",
"fontSizeModifier": 0.8
},
{
"input": ["G", "M"],
"output": "⌫",
"fill": "#f9adff"
},
{
"input": ["N", "E"],
"output": "⊥",
"fill": "#f58e8e"
},
{
"input": ["I", "O"],
"output": "⌥",
"fill": "#39f785"
}
],
[
{
"input": ["R", "S"],
"output": "⇧",
"fill": "#f9adff"
},
{
"input": ["E", "I"],
"output": "⇧",
"fill": "#f9adff"
}
],
[
{
"input": ["R", "T"],
"output": "ctrl",
"fill": "#fdff80"
},
{
"input": ["N", "I"],
"output": "ctrl",
"fill": "#fdff80"
}
],
[
{
"input": ["C", "P"],
"output": "📋",
"fill": "#fdff80"
},
{
"input": ["K", "I"],
"output": "❖",
"fill": "#f58e8e"
}
],
[
{
"input": ["F", "T"],
"output": "↵",
"fill": "#9ccaff"
},
{
"input": ["N", "U"],
"output": "💾",
"fill": "#9ccaff"
}
]
]
}

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View file

@ -1,13 +1,15 @@
# Layout-lens
> NOTE: this project cannot yet render combos, which will change soon
This is a quickly-thrown-together set of scripts for generating SVG previews of keyboard layouts. For example configurations check out any config in the `keyboards` directory of this repository. To run this on your config simply do
```sh
nix run github:mateiadrielrafael/keyswirl#layout-lens my-config.json out.svg
```
## Future improvements
This project does not render chords involving rotated keys properly. Moreover, chord definitions in general can be a little verboose.
## Technical details
The code isn't very well written (i.e.: no error handling, only contains the features I needed myself, etc). I'd rewrite this in a better language given the motivation, but the current version does the job just fine. If you want to contribute a layout preset, add it to [./src/layouts](./src/layouts) and then modify the enum in [./src/types.ts](./src/types.ts) to know about it's existence.

View file

@ -9,12 +9,13 @@ import {
SpecialSymbols,
LayoutMeasurements,
LayoutColorscheme,
ChordConfig,
ElementLayout,
} from "./types";
import split_3x5_2 from "./layouts/split_3x5_2";
import alpha_staggered_double_switch from "./layouts/alpha_staggered_double_switch";
const defaultMeasurements: LayoutMeasurements = {
imagePadding: 20,
keySize: 60,
keyPadding: 2,
keyCornerRadius: 5,
@ -30,6 +31,20 @@ const defaultColorscheme: LayoutColorscheme = {
blLayerColor: "purple",
};
const defaultElementLayout: ElementLayout = {
mainToChordsGap: 10,
imagePadding: 20,
groupsPerRow: 2,
groupPadding: 20,
};
function parseSymbol(s: string) {
const special = SpecialSymbols[s];
const isNumber = String(parseInt(s)) == s;
if (isNumber || special === undefined) return s;
return special as SpecialSymbols;
}
export function parseConfig(input: string): Config {
const parsed = JSON.parse(input);
@ -42,16 +57,20 @@ export function parseConfig(input: string): Config {
}
return {
keys: (parsed.keys as string[][]).map((k) =>
k.map((s) => {
const special = SpecialSymbols[s];
const isNumber = String(parseInt(s)) == s;
if (isNumber || special === undefined) return s;
return special as SpecialSymbols;
}),
keys: (parsed.keys as string[][]).map((k) => k.map(parseSymbol)),
chords: ((parsed.chords as ChordConfig[][]) || []).map((group) =>
group.map((chord) => ({
...chord,
input: chord.input.map((k) => parseSymbol(k as string)),
output: parseSymbol(chord.output as string),
})),
),
colorscheme: { ...defaultColorscheme, ...parsed.colorscheme },
measurements: { ...defaultMeasurements, ...parsed.measurements },
elementLayout: {
...defaultElementLayout,
...parsed.elementLayout,
},
layout,
};
}
@ -77,6 +96,8 @@ export function makeLayout(config: Config): Layout {
keys: config.keys.map((k) => key(...(k as Arguments<typeof key>))),
colorscheme: config.colorscheme,
measurements: config.measurements,
elementLayout: config.elementLayout,
chords: config.chords,
visual: predefined.visual,
size: predefined.size,
};

View file

@ -1,4 +1,10 @@
import { VisualKey, VisualLayout } from "./types";
import type {
KeySymbol,
Layout,
LayoutMeasurements,
VisualKey,
VisualLayout,
} from "./types";
import * as V from "./vec2";
export function visualKey(
@ -70,3 +76,28 @@ export function scaleVisual(visual: VisualKey, amount: number): VisualKey {
size: V.scale(visual.size, amount),
};
}
export function scaleMeasurements(
measurements: LayoutMeasurements,
amount: number,
): LayoutMeasurements {
return {
imagePadding: measurements.imagePadding * amount,
keySize: measurements.keySize * amount,
keyPadding: measurements.keyPadding * amount,
keyCornerRadius: measurements.keyCornerRadius * amount,
keyStrokeWidth: measurements.keyStrokeWidth * amount,
mainToChordsGap: measurements.mainToChordsGap * amount,
};
}
export function findKeyByLabel(
layout: Layout,
label: KeySymbol,
): VisualKey | null {
for (let i = 0; i < layout.keys.length; i++) {
if (layout.keys[i].main === label) return layout.visual[i];
}
return null;
}

View file

@ -26,7 +26,7 @@ const layout: PredefinedLayout = {
L.visualKey([7.75, 3.25]),
L.cols([5, 0], block, offsets),
].flat(),
size: [10.8, 4],
size: [10.8, 4.25],
};
export default layout;

View file

@ -1,7 +1,8 @@
import { tag, px } from "./svg";
import { children, tag, px } from "./svg";
import * as L from "./layout";
import * as V from "./vec2";
import {
ChordConfig,
KeyboardKey,
KeySymbol,
Layout,
@ -17,57 +18,63 @@ function textContents(input: KeySymbol): string {
return input;
}
function renderKey(
visual: VisualKey,
key: KeyboardKey,
colorscheme: LayoutColorscheme,
measurements: LayoutMeasurements,
) {
const withPadding = {
position: V.add(visual.position, measurements.keyPadding),
size: V.add(visual.size, -2 * measurements.keyPadding),
};
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 centerX = visual.position[0] + visual.size[0] / 2;
const centerY = visual.position[1] + visual.size[1] / 2;
const textAttribs = {
"text-anchor": "middle",
"dominant-baseline": "central",
"font-family": "Helvetica",
};
const textColor = (input: KeySymbol, _default: string): string => {
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;
};
}
return tag(
"g",
{
transform:
visual.angle && visual.angle !== 0
? `rotate(${visual.angle}, ${centerX}, ${centerY})`
: "",
},
[
tag("rect", {
width: px(withPadding.size[0]),
height: px(withPadding.size[1]),
x: withPadding.position[0],
y: withPadding.position[1],
rx: measurements.keyCornerRadius,
fill: colorscheme.keyFill,
stroke: colorscheme.keyStroke,
"stroke-width": px(measurements.keyStrokeWidth),
}),
function renderKey(
visual: VisualKey,
key: KeyboardKey,
colorscheme: LayoutColorscheme,
measurements: LayoutMeasurements,
flags: KeyRenderingFlags,
) {
const withPadding = applyKeyPadding(visual, measurements);
const center = keyCenter(visual);
const textOverlays = flags.text
? [
tag(
"text",
{
x: centerX,
y: centerY,
x: center[0],
y: center[1],
textLength: px(withPadding.size[1] / 2),
fill: textColor(key.main, colorscheme.mainLayerColor),
fill: textColor(colorscheme, key.main, colorscheme.mainLayerColor),
...textAttribs,
},
textContents(key.main),
@ -77,7 +84,7 @@ function renderKey(
{
x: withPadding.position[0] + withPadding.size[0] / 6,
y: withPadding.position[1] + withPadding.size[1] / 6,
fill: textColor(key.tlLayer, colorscheme.tlLayerColor),
fill: textColor(colorscheme, key.tlLayer, colorscheme.tlLayerColor),
"font-size": "66%",
...textAttribs,
"text-anchor": "start",
@ -89,7 +96,7 @@ function renderKey(
{
x: withPadding.position[0] + (9 * withPadding.size[0]) / 10,
y: withPadding.position[1] + withPadding.size[1] / 6,
fill: textColor(key.trLayer, colorscheme.trLayerColor),
fill: textColor(colorscheme, key.trLayer, colorscheme.trLayerColor),
"font-size": "66%",
...textAttribs,
"text-anchor": "end",
@ -101,41 +108,257 @@ function renderKey(
{
x: withPadding.position[0] + withPadding.size[0] / 10,
y: withPadding.position[1] + (5 * withPadding.size[1]) / 6,
fill: textColor(key.blLayer, colorscheme.blLayerColor),
fill: textColor(colorscheme, key.blLayer, colorscheme.blLayerColor),
"font-size": "66%",
...textAttribs,
"text-anchor": "start",
},
textContents(key.blLayer),
),
].join("\n"),
]
: [];
return tag(
"g",
{
transform:
visual.angle && visual.angle !== 0
? `rotate(${visual.angle}, ${center[0]}, ${center[1]})`
: "",
},
children(
tag("rect", {
width: px(withPadding.size[0]),
height: px(withPadding.size[1]),
x: withPadding.position[0],
y: withPadding.position[1],
rx: measurements.keyCornerRadius,
fill: colorscheme.keyFill,
stroke: colorscheme.keyStroke,
"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,
"stroke-width": px(measurements.keyStrokeWidth),
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
),
}),
dottedIndicator(first),
dottedIndicator(second),
tag(
"text",
{
x: middle[0],
y: middle[1],
"font-size": `${(chord.fontSizeModifier || 1) * 70}%`,
fill: textColor(
colorscheme,
chord.output,
colorscheme.mainLayerColor,
),
...textAttribs,
"dominant-baseline": "middle",
},
textContents(chord.output),
),
),
);
}
export function renderLayout(layout: Layout) {
return tag(
"svg",
{
viewBox: [
-layout.measurements.imagePadding,
-layout.measurements.imagePadding,
2 * layout.measurements.imagePadding +
layout.size[0] * layout.measurements.keySize,
2 * layout.measurements.imagePadding +
layout.size[1] * layout.measurements.keySize,
].join(" "),
xmlns: "http://www.w3.org/2000/svg",
"xmlns:xlink": "http://www.w3.org/1999/xlink",
},
layout.visual
.map((key, index) =>
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,
},
),
)
.join("\n"),
);
// }}}
// {{{ 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
return tag(
"svg",
{
viewBox: [
-layout.elementLayout.imagePadding,
-layout.elementLayout.imagePadding,
2 * layout.elementLayout.imagePadding + totalWidth,
2 * layout.elementLayout.imagePadding + totalHeight,
].join(" "),
xmlns: "http://www.w3.org/2000/svg",
"xmlns:xlink": "http://www.w3.org/1999/xlink",
},
children(
...mainKeys,
tag(
"g",
{
transform: `translate(0 ${
layout.size[1] * layout.measurements.keySize +
layout.elementLayout.mainToChordsGap
})`,
},
children(...chords),
),
),
);
// }}}
}

View file

@ -5,6 +5,10 @@ function indent(amount: number, text: string) {
.join("\n");
}
export function children(...many: string[]): string {
return many.join("\\n");
}
export function tag(
name: string,
attributes: Record<string, string | number | undefined>,

View file

@ -38,24 +38,42 @@ export interface LayoutColorscheme {
blLayerColor: string;
}
export enum PredefinedLayoutName {
split_3x5_2,
alpha_staggered_double_switch,
}
export interface LayoutMeasurements {
imagePadding: number;
keySize: number;
keyPadding: number;
keyCornerRadius: number;
keyStrokeWidth: number;
}
export enum PredefinedLayoutName {
split_3x5_2,
alpha_staggered_double_switch,
}
export type ChordName = string;
export interface ChordConfig {
input: KeySymbol[];
output: KeySymbol;
fill: string;
stroke?: string;
fontSizeModifier?: number;
}
export interface ElementLayout {
groupsPerRow: number;
groupPadding: number;
imagePadding: number;
mainToChordsGap: number;
}
export interface Config {
keys: KeySymbol[][];
chords: ChordConfig[][];
layout: PredefinedLayoutName;
colorscheme: LayoutColorscheme;
measurements: LayoutMeasurements;
elementLayout: ElementLayout;
}
export type PredefinedLayout = {
@ -64,7 +82,10 @@ export type PredefinedLayout = {
};
export interface Layout extends PredefinedLayout {
keys: KeyboardKey[];
colorscheme: LayoutColorscheme;
measurements: LayoutMeasurements;
elementLayout: ElementLayout;
keys: KeyboardKey[];
chords: ChordConfig[][];
}