Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement SVG tool for visualizing 23-limit JI lattices #459

Merged
merged 1 commit into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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