Skip to content

Commit

Permalink
Merge pull request #459 from xenharmonic-devs/23-limit-lattice
Browse files Browse the repository at this point in the history
Implement SVG tool for visualizing 23-limit JI lattices
  • Loading branch information
frostburn authored Oct 31, 2023
2 parents c75afb2 + 8f5c1c1 commit f4f6ea3
Show file tree
Hide file tree
Showing 6 changed files with 412 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -929,6 +929,7 @@ watch(degreeDownCode, (newValue) =>
</li>
<li><RouterLink to="/">Build Scale</RouterLink></li>
<li><RouterLink to="/analysis">Analysis</RouterLink></li>
<li><RouterLink to="/lattice">Lattice</RouterLink></li>
<li><RouterLink to="/vk">Virtual Keyboard</RouterLink></li>
<li v-if="showVirtualQwerty">
<RouterLink to="/qwerty">Virtual QWERTY</RouterLink>
Expand Down
342 changes: 342 additions & 0 deletions src/components/ScaleLattice.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,342 @@
<script setup lang="ts">
import type { Interval, Scale } from "scale-workshop-core";
import { computed, onMounted, type ComputedRef, ref, onUnmounted } from "vue";
import { monzoEuclideanDistance } from "@/utils";
import { kCombinations } from "xen-dev-utils";
const props = defineProps<{
scale: Scale;
heldScaleDegrees: Set<number>;
}>();
type Coord = { x: number; y: number };
type Node = {
x: number;
y: number;
label: string;
index: number;
fill: string;
};
type Coords = Coord[];
const coords: Coords =
// Coords for every prime up to 23, indexes correspond to monzo indexes
// based on Kraig Grady's coordinate system https://anaphoria.com/wilsontreasure.html
[
{
x: 0,
y: 0,
},
{
x: 40,
y: 0,
},
{
x: 0,
y: -40,
},
{
x: 13,
y: -11,
},
{
x: -14,
y: -18,
},
{
x: -8,
y: -4,
},
{
x: -5,
y: -32,
},
{
x: 7,
y: -25,
},
{
x: 20,
y: -6,
},
];
const container = ref<HTMLDivElement | null>(null);
const adjustLatticeSize = () => {
if (container.value) {
const containerBox = container.value.getBoundingClientRect();
const containerHeight = window.innerHeight - containerBox.top;
const image: HTMLElement | null = container.value.querySelector("#lattice");
if (container.value && image) {
image.style.width = `100%`;
image.style.height = `${containerHeight}px`;
}
}
};
onMounted(() => {
adjustLatticeSize();
window.addEventListener("resize", adjustLatticeSize);
});
onUnmounted(() => {
window.removeEventListener("resize", adjustLatticeSize);
});
// === Computed state ===
const canLatticePrimes = computed(() => {
try {
return props.scale.intervals.reduce(
(canLattice: boolean, interval: Interval) =>
canLattice &&
interval.monzo
.toIntegerMonzo()
.slice(9) // hardcoded for now to the 23-limit
.reduce(
(isZero: boolean, component: number) => isZero && component === 0,
true
),
true
);
} catch {
return false;
}
});
function maybeGetPrimeEquave(equave: Interval): number | null {
try {
const intergerMonzo: number[] = equave.monzo.toIntegerMonzo();
return intergerMonzo.reduce(
(
primeIndex: number | null,
component: number,
i: number
): number | null => {
if (component === 1 && !primeIndex) {
return i;
}
if (component !== 0) {
throw new Error("Equave is not a prime number");
}
if (component === 0) {
return primeIndex;
}
return null;
},
null
);
} catch {
return null;
}
}
const equavePrimeIndex = computed(() =>
maybeGetPrimeEquave(props.scale.equave)
);
const nodes: ComputedRef<Node[] | string> = computed(() => {
try {
if (equavePrimeIndex.value !== 0) {
return "The lattice currently only supports scales with a 2/1 equave";
}
if (props.scale.intervals.length <= 1) {
return "A scale must contain at least two intervals";
}
return props.scale.intervals.map(({ monzo, name: label }, index) => {
const node: Node = monzo.toIntegerMonzo().reduce(
(node, component, i): Node => {
const { x, y } = node;
if (i === equavePrimeIndex.value) {
return node;
}
const componentVal = component.valueOf();
if (componentVal === 0) {
return node;
} else {
const vector = coords[i] || { x: 0, y: 0 };
return {
...node,
x: x + vector.x * componentVal,
y: y + vector.y * componentVal,
};
}
},
{ x: 0, y: 0, label, index, fill: "white" }
);
return node;
});
} catch {
return "Cannot make lattice of non JI scale.";
}
});
const nodesByLabel: ComputedRef<{ [key: string]: Node }> = computed(() =>
Array.isArray(nodes.value)
? nodes.value.reduce((acc, node) => ({ ...acc, [node.label]: node }), {})
: {}
);
const edges = computed(() => {
try {
if (!Array.isArray(nodes.value)) {
return [];
}
const combinations = kCombinations(props.scale.intervals, 2);
return combinations
.map(([a, b]) => {
return {
distance: monzoEuclideanDistance(
equavePrimeIndex.value ?? -1, //adding a type check outside of this map doesn't seem to work so passing in -1
a.monzo.toIntegerMonzo(),
b.monzo.toIntegerMonzo()
),
pair: [a.name, b.name],
};
})
.filter(({ distance }) => distance === 1);
} catch {
return [];
}
});
const svgElement = ref<HTMLElement | null>(null);
const viewBox = computed(() => {
if (svgElement.value) {
return [
...svgElement.value.querySelectorAll("text"),
...svgElement.value.querySelectorAll("circle"),
].reduce(
({ minX, maxX, minY, maxY }, el) => {
const { x, y, width, height } = el.getBBox({
stroke: true,
fill: true,
});
return {
minX: Math.min(minX, x),
minY: Math.min(minY, y),
maxX: Math.max(maxX, x + width),
maxY: Math.max(maxY, y + height),
};
},
{
minX: Infinity,
minY: Infinity,
maxX: -Infinity,
maxY: -Infinity,
}
);
}
return {
minX: 0,
minY: 0,
maxX: 0,
maxY: 0,
};
});
function isNodeErrorMessage(nodeValue: any): nodeValue is string {
return typeof nodeValue === "string";
}
const error = computed(() => {
if (!canLatticePrimes.value) {
return "Can only make lattice of JI scales up to 23-limit";
}
if (isNodeErrorMessage(nodes.value)) {
return (
nodes.value ||
"There was an unknown error while making this lattice, please try again or with another scale."
);
}
return null;
});
</script>

<template>
<div ref="container" class="container">
<div v-if="error">
<p>{{ error }}</p>
</div>
<div v-else>
<svg
v-if="Array.isArray(nodes)"
ref="svgElement"
id="lattice"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid meet"
:viewBox="
[
viewBox.minX,
viewBox.minY,
viewBox.maxX - viewBox.minX,
viewBox.maxY - viewBox.minY,
].join(' ')
"
>
<line
v-for="edge of edges"
class="edge"
:key="edge.pair.join()"
:x1="nodesByLabel[edge.pair[0]].x"
:x2="nodesByLabel[edge.pair[1]].x"
:y1="nodesByLabel[edge.pair[0]].y"
:y2="nodesByLabel[edge.pair[1]].y"
/>
<circle
v-for="(node, index) of nodes"
:key="index"
:cx="node.x"
:cy="node.y"
r="2.5"
:class="{ node: true, heldDegree: heldScaleDegrees.has(node.index) }"
/>
<!-- Separating the iteration of the labels so that circles don't overlap any text -->
<text
v-for="(node, index) of nodes"
:key="index"
class="node-text"
:x="node.x"
:y="node.y - 4"
>
{{ node.label }}
</text>
</svg>
</div>
</div>
</template>

<style scoped>
.container {
flex-grow: 1;
}
svg {
background-color: transparent;
}
.edge {
stroke: var(--color-text);
stroke-width: 0.5;
}
.node {
fill: var(--color-text);
}
.node.heldDegree {
fill: var(--color-accent);
}
.node-text {
font-family: sans-serif;
font-size: 7px;
fill: var(--color-accent-text-btn);
text-anchor: middle;
stroke: var(--color-background);
stroke-width: 0.2;
}
</style>
5 changes: 5 additions & 0 deletions src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ const router = createRouter({
name: "analysis",
component: () => import("../views/AnalysisView.vue"),
},
{
path: "/lattice",
name: "lattice",
component: () => import("../views/LatticeView.vue"),
},
{
path: "/midi",
name: "midi",
Expand Down
21 changes: 21 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,24 @@ export function computedAndError<T>(
const error = computed(() => valueAndError.value[1]);
return [value, error];
}

// Calculates euclidean distance for monzos, as it assumes that if points are not of the same dimension, then the missing dimensions have a value of zero.
export function monzoEuclideanDistance(
equavePrimeIndex: number,
point1: number[],
point2: number[]
): number {
// Ensure that both points have the same dimension
const maxDimension = Math.max(point1.length, point2.length);
// Pad the shorter point with zeroes to match the longer point's dimension
const point1_ = point1.concat(Array(maxDimension - point1.length).fill(0));
const point2_ = point2.concat(Array(maxDimension - point2.length).fill(0));

const distance = Math.hypot(
...point1_.map((coord1, index) => {
return index === equavePrimeIndex ? 0 : point2_[index] - coord1;
})
);

return distance;
}
4 changes: 3 additions & 1 deletion src/views/AboutView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
Sevish - <i>UI/UX designer</i><br />
Vincenzo Sicurella - <i>developer</i><br />
Lajos Mészáros - <i>developer</i><br />
Forrest Cahoon - <i>developer</i>
Forrest Cahoon - <i>developer</i> <br />
Videco - <i>developer</i> <br />
Kraig Grady - <i>lattice advisor</i>
</p>
</div>
</div>
Expand Down
Loading

0 comments on commit f4f6ea3

Please sign in to comment.