Skip to content
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Math Visualizer is an interactive collection of mathematical visualizations buil
> glyph outlines become one closed path, the path's DFT becomes a chain of rotating
> circles, and the chain's tip traces the letters live (pen lifts between glyphs).
> Hundreds of circles render in one instanced draw call.
> Share a message with `#/fourier?text=YOUR+TEXT` (`&n=` sets the epicycle count) —
> the URL updates as you type, and **Copy link** puts it on the clipboard.
>
> The midpoint-on-circle and ColorCycle rules remain in the codebase as alternative
> examples. See [`docs/superpowers/specs/`](docs/superpowers/specs/) for designs and
Expand Down
18 changes: 17 additions & 1 deletion web/src/lib/__tests__/router.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseHash, DEFAULT_LAB, LAB_IDS } from '../router';
import { parseHash, parseHashQuery, buildQuery, DEFAULT_LAB, LAB_IDS } from '../router';

describe('parseHash', () => {
it('maps #/fourier to fourier', () => {
Expand All @@ -22,3 +22,19 @@ describe('parseHash', () => {
for (const id of LAB_IDS) expect(parseHash(`#/${id}`)).toBe(id);
});
});

describe('parseHashQuery / buildQuery', () => {
it('extracts the query part of a hash route', () => {
expect(parseHashQuery('#/fourier?text=HI&n=5')).toBe('text=HI&n=5');
expect(parseHashQuery('#/fourier')).toBe('');
expect(parseHashQuery('')).toBe('');
});
it('route id parsing ignores the query', () => {
expect(parseHash('#/fourier?text=HI')).toBe('fourier');
});
it('buildQuery omits undefined/empty values and encodes the rest', () => {
expect(buildQuery({ text: 'HELLO WORLD', n: '12' })).toBe('text=HELLO+WORLD&n=12');
expect(buildQuery({ text: undefined, n: '' })).toBe('');
expect(new URLSearchParams(buildQuery({ text: 'a&b=c' })).get('text')).toBe('a&b=c');
});
});
29 changes: 29 additions & 0 deletions web/src/lib/components/__tests__/FourierLab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,32 @@ describe('FourierLab.svelte', () => {
expect(getByText('Sierpinski Pyramid', { selector: 'h2' })).toBeTruthy();
});
});

describe('FourierLab.svelte — shareable link params', () => {
beforeEach(() => {
dispatchSpy.mockClear();
updateRuleConfigSpy.mockClear();
});

it('reads text and n from the hash query and uses them for the first push', async () => {
navigate('fourier', 'text=HELLO&n=12');
const { getByLabelText } = render(App);
await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalled());
const cfg = updateRuleConfigSpy.mock.calls[0][0] as { epicycles: number };
expect(cfg.epicycles).toBe(12);
expect((getByLabelText('Text to trace') as HTMLInputElement).value).toBe('HELLO');
});

it('keeps the URL in sync as the text changes and offers a copy-link button', async () => {
navigate('fourier');
const { getByLabelText, getByTitle } = render(App);
await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalledTimes(1));
expect(location.hash).toBe('#/fourier'); // defaults are omitted from the link

const input = getByLabelText('Text to trace') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'ABC' } });
await vi.waitFor(() => expect(updateRuleConfigSpy).toHaveBeenCalledTimes(2));
expect(location.hash).toBe('#/fourier?text=ABC');
expect(getByTitle('Copy a link to this message')).toBeTruthy();
});
});
77 changes: 74 additions & 3 deletions web/src/lib/components/labs/FourierLab.svelte
Original file line number Diff line number Diff line change
@@ -1,19 +1,75 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { onDestroy, untrack } from 'svelte';
import LabShell from '../LabShell.svelte';
import FormulaPanel from '../FormulaPanel.svelte';
import type { LabApi } from '../labApi.svelte';
import { cmd } from '../../playback/commands';
import { textToPath } from '../../fourier/textPath';
import { readSummary, type FourierSummary } from '../../fourier/summary';
import { route, replaceQuery } from '../../router.svelte';
import { buildQuery } from '../../router';

const DEFAULT_TEXT = 'POIETIC TECH';
const SAMPLES = 2000;
const MAX_EPICYCLES = 2000;
const DEBOUNCE_MS = 150;
const DEFAULT_EPICYCLES = 2000;
const MAX_TEXT = 40;

let text = $state(DEFAULT_TEXT);
let epicycles = $state(2000);
/** Read the shareable-link params from a hash query: `text=…` and `n=…` (epicycles). */
function paramsOf(query: string) {
const p = new URLSearchParams(query);
const t = p.get('text');
const n = Math.floor(Number(p.get('n')));
return {
text: t !== null && t.trim() !== '' ? t.slice(0, MAX_TEXT) : undefined,
epicycles: Number.isFinite(n) && n >= 1 ? Math.min(n, MAX_EPICYCLES) : undefined,
};
}
const initialParams = paramsOf(route.query);

let text = $state(initialParams.text ?? DEFAULT_TEXT);
let epicycles = $state(initialParams.epicycles ?? DEFAULT_EPICYCLES);
/** Query we last wrote (or consumed), so our own replaceQuery() doesn't re-trigger a push. */
let appliedQuery = route.query;
let copied = $state(false);

/** Keep the URL a shareable link to the current message (defaults are omitted). */
function syncUrl() {
const q = buildQuery({
text: text === DEFAULT_TEXT ? undefined : text,
n: epicycles === DEFAULT_EPICYCLES ? undefined : String(epicycles),
});
appliedQuery = q;
replaceQuery(q);
}

// A pasted link / back-forward while on this page: adopt its params and redraw.
$effect(() => {
const q = route.query;
untrack(() => {
if (q === appliedQuery) return;
appliedQuery = q;
const p = paramsOf(q);
const nextText = p.text ?? DEFAULT_TEXT;
const nextN = p.epicycles ?? DEFAULT_EPICYCLES;
if (nextText !== text || nextN !== epicycles) {
text = nextText;
epicycles = nextN;
void push();
}
});
});

async function copyLink() {
try {
await navigator.clipboard.writeText(location.href);
copied = true;
window.setTimeout(() => { copied = false; }, 1500);
} catch (err) {
console.warn('copy link failed:', err);
}
}
/** True when the current text produced no drawable path (blank, or no outline). */
let empty = $state(false);
/** The DFT terms behind the current trace, read from the engine after each config push (null when nothing is drawn). */
Expand Down Expand Up @@ -42,6 +98,7 @@
}
if (my !== gen || !api?.engine) return; // superseded, or shell torn down / not ready yet
empty = path.length === 0;
syncUrl();
if (empty) {
summary = null;
return;
Expand Down Expand Up @@ -146,6 +203,9 @@
title="Epicycles"
/>
</label>
<button class="copy" type="button" onclick={copyLink} title="Copy a link to this message">
{copied ? 'Copied ✓' : 'Copy link'}
</button>
{#if empty}<span class="hint">Nothing to draw — type some letters.</span>{/if}
{/snippet}
</LabShell>
Expand Down Expand Up @@ -196,4 +256,15 @@
}
span.swatch.pen { background: #fa9959; }
span.swatch.ink { background: #a6d9f2; }
.copy {
background: #2a2a2f;
color: #eee;
border: 1px solid #3a3a40;
border-radius: 4px;
padding: 0.35rem 0.7rem;
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
}
.copy:hover { background: #34343a; }
</style>
38 changes: 30 additions & 8 deletions web/src/lib/router.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,37 @@
// Reactive hash router. `route.id` is a $state field on an exported object
// (a reassigned `let` export is not allowed for runes) — mutate `route.id`,
// never reassign `route`.
import { parseHash, type LabId } from './router';
// Reactive hash router. `route` is an exported $state object (a reassigned
// `let` export is not allowed for runes) — mutate its fields, never reassign it.
// route.id — which lab ('#/fourier' → 'fourier')
// route.query — the query part of the hash ('#/fourier?text=HI' → 'text=HI')
import { parseHash, parseHashQuery, type LabId } from './router';

const initialHash = typeof location !== 'undefined' ? location.hash : '';

export const route = $state<{ id: LabId }>({ id: parseHash(initialHash) });
export const route = $state<{ id: LabId; query: string }>({
id: parseHash(initialHash),
query: parseHashQuery(initialHash),
});

/** Navigate programmatically. Updates the hash (so the URL is bookmarkable) and the reactive route. */
export function navigate(id: LabId) {
if (typeof location !== 'undefined') location.hash = `#/${id}`;
function hashFor(id: LabId, query: string): string {
return query ? `#/${id}?${query}` : `#/${id}`;
}

/** Navigate programmatically (adds a history entry). Updates the hash and the reactive route. */
export function navigate(id: LabId, query = '') {
if (typeof location !== 'undefined') location.hash = hashFor(id, query);
route.id = id;
route.query = query;
}

/**
* Rewrite the current route's query in place — no history entry, and
* history.replaceState fires no hashchange — so a lab can keep the URL in
* sync with its live state (a shareable link) without re-triggering itself.
*/
export function replaceQuery(query: string) {
if (typeof history !== 'undefined' && typeof location !== 'undefined') {
history.replaceState(history.state, '', `${location.pathname}${location.search}${hashFor(route.id, query)}`);
}
route.query = query;
}

let installed = false;
Expand All @@ -21,5 +42,6 @@ export function installRouter() {
installed = true;
window.addEventListener('hashchange', () => {
route.id = parseHash(location.hash);
route.query = parseHashQuery(location.hash);
});
}
13 changes: 13 additions & 0 deletions web/src/lib/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,16 @@ export function parseHash(hash: string): LabId {
const id = hash.replace(/^#\/?/, '').split(/[/?#]/)[0];
return (LAB_IDS as readonly string[]).includes(id) ? (id as LabId) : DEFAULT_LAB;
}

/** The query part of a hash route: `#/fourier?text=HI&n=5` → 'text=HI&n=5' ('' when absent). */
export function parseHashQuery(hash: string): string {
const i = hash.indexOf('?');
return i === -1 ? '' : hash.slice(i + 1);
}

/** Serialize params to a query string, omitting undefined/empty values. */
export function buildQuery(params: Record<string, string | undefined>): string {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== '') q.set(k, v);
return q.toString();
}
Loading