Graph calculator improvmentes
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
This commit is contained in:
@@ -18,6 +18,8 @@
|
||||
Narrow screens fold the book to the one page in view, with its neighbours
|
||||
as chips along the top. */
|
||||
|
||||
import type { ArticleTab } from '~/composables/useArticleTabs'
|
||||
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -34,7 +36,18 @@ const { collapsed } = useSidebar()
|
||||
|
||||
const activeTab = computed(() => tabAt(tabs.value, route.path))
|
||||
const activeIdentity = computed(() => activeTab.value?.identity ?? null)
|
||||
const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0)
|
||||
/* the book lies open on a reader route, and on one of the wiki's own pages
|
||||
while the book holds a page of it — asked plain, those pages draw
|
||||
themselves instead */
|
||||
const visible = computed(
|
||||
() => (isReaderPath(route.path) || tabAt(tabs.value, route.path) !== null) && tabs.value.length > 0
|
||||
)
|
||||
|
||||
/* what a page of the book is called: an article wears its title; one of the
|
||||
wiki's own pages is called by what it is, in the reader's language */
|
||||
function tabLabel(tab: ArticleTab): string {
|
||||
return tab.tool ? t(TOOL_LABEL_KEYS[tab.tool]) : tab.title
|
||||
}
|
||||
|
||||
const trackEl = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -157,11 +170,13 @@ watch(collapsed, () => {
|
||||
})
|
||||
|
||||
/* every page of the book should hold its article: pull the ones the route
|
||||
host hasn't already written in (background pages, client-side only) */
|
||||
host hasn't already written in (background pages, client-side only). The
|
||||
wiki's own pages carry no article to fetch — their surface fetches its
|
||||
own map when it is turned to */
|
||||
watch(
|
||||
tabs,
|
||||
() => {
|
||||
for (const t of tabs.value) void load(t.slug)
|
||||
for (const t of tabs.value) if (!t.tool) void load(t.slug)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -215,11 +230,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
:aria-selected="tab.identity === activeIdentity"
|
||||
:title="t('book.chipHint', { title: tab.title })"
|
||||
:title="t('book.chipHint', { title: tabLabel(tab) })"
|
||||
@click="turnTo(tab.identity)"
|
||||
@keydown.enter.prevent="turnTo(tab.identity)"
|
||||
>
|
||||
<span class="chip-title">{{ tab.title }}</span>
|
||||
<span class="chip-title">{{ tabLabel(tab) }}</span>
|
||||
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
@@ -229,17 +244,28 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
</nav>
|
||||
|
||||
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
|
||||
<ArticlePane
|
||||
v-for="(tab, i) in tabs"
|
||||
:key="tab.identity"
|
||||
:identity="tab.identity"
|
||||
:slug="tab.slug"
|
||||
:index="i"
|
||||
:active="tab.identity === activeIdentity"
|
||||
@activate="turnTo(tab.identity)"
|
||||
@close="closeTab(tab.identity)"
|
||||
@remove="removeArticle(tab.identity)"
|
||||
/>
|
||||
<!-- an article's page holds its text; one of the wiki's own pages
|
||||
holds the live surface itself, the map or the plane -->
|
||||
<template v-for="(tab, i) in tabs" :key="tab.identity">
|
||||
<ToolPane
|
||||
v-if="tab.tool"
|
||||
:tool="tab.tool"
|
||||
:index="i"
|
||||
:active="tab.identity === activeIdentity"
|
||||
@activate="turnTo(tab.identity)"
|
||||
@close="closeTab(tab.identity)"
|
||||
/>
|
||||
<ArticlePane
|
||||
v-else
|
||||
:identity="tab.identity"
|
||||
:slug="tab.slug"
|
||||
:index="i"
|
||||
:active="tab.identity === activeIdentity"
|
||||
@activate="turnTo(tab.identity)"
|
||||
@close="closeTab(tab.identity)"
|
||||
@remove="removeArticle(tab.identity)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
|
||||
@@ -249,7 +275,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
class="dock-spine"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="t('book.dockHint', { title: tab.title })"
|
||||
:title="t('book.dockHint', { title: tabLabel(tab) })"
|
||||
@click="turnTo(tab.identity)"
|
||||
@keydown.enter.prevent="turnTo(tab.identity)"
|
||||
>
|
||||
@@ -258,7 +284,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="spine-title">{{ tab.title }}</span>
|
||||
<span class="spine-title">{{ tabLabel(tab) }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
268
frontend/app/components/CalculatorStage.vue
Normal file
268
frontend/app/components/CalculatorStage.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<script setup lang="ts">
|
||||
import type { PlaneFunction, PlotFunction } from '~/utils/plot-expression'
|
||||
import type { PlotSeries } from './PlotCanvas.vue'
|
||||
|
||||
/* The graphing calculator itself: the plane owns the surface, the inputs
|
||||
dock to its left edge — foldable, so the plane can have every pixel.
|
||||
Each field reads as y = f(x), but a text that carries its own "y" or an
|
||||
"=" — "y^2 + x^2 = 1" — is a relation F(x, y) and its zero set is drawn,
|
||||
so any equation in two variables plots. Everything is compiled as it is
|
||||
typed; one that reads wrong goes red and simply stops being drawn. The
|
||||
rows and the dock's fold are the reader's own — kept between visits and
|
||||
between instances, the way the link map keeps its display switch.
|
||||
The /calculator address draws this as the page; the book lays it on one
|
||||
of its pages (ToolPane), an instance beside the open articles. */
|
||||
|
||||
interface PlotRow {
|
||||
id: number
|
||||
expr: string
|
||||
color: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
/* a curve wears one of eight hues, each readable on paper and on slate */
|
||||
const PALETTE = [
|
||||
'#e5484d', '#0091ff', '#46a758', '#f76808', '#8e4ec6', '#12a594', '#e93d82', '#3e63dd',
|
||||
]
|
||||
const STORAGE_KEY = 'mathew-calculator-plots'
|
||||
const DOCK_KEY = 'mathew-calc-dock'
|
||||
const MAX_ROWS = 12
|
||||
|
||||
let nextId = 1
|
||||
const seedRows = (): PlotRow[] => [
|
||||
{ id: nextId++, expr: 'sin(x)', color: PALETTE[0]!, visible: true },
|
||||
{ id: nextId++, expr: 'x^2 + y^2 = 9', color: PALETTE[1]!, visible: true },
|
||||
]
|
||||
|
||||
const rows = ref<PlotRow[]>(seedRows())
|
||||
|
||||
/* one compile pass per render: the rows read alongside what they became —
|
||||
a function or relation to plot, and a reason for the ones that read wrong
|
||||
(an empty field is nothing asked for, not a mistake). A relation carries
|
||||
its own y, so the field drops the "y =" it would otherwise wear. */
|
||||
interface PlotEntry {
|
||||
row: PlotRow
|
||||
kind: 'function' | 'relation' | null
|
||||
fn: PlotFunction | PlaneFunction | null
|
||||
error: 'unknown' | 'syntax' | null
|
||||
}
|
||||
|
||||
const entries = computed<PlotEntry[]>(() =>
|
||||
rows.value.map((row) => {
|
||||
const src = row.expr.trim()
|
||||
if (!src) return { row, kind: null, fn: null, error: null }
|
||||
const result = compilePlotExpression(src)
|
||||
if (result.ok) return { row, kind: result.kind, fn: result.fn, error: null }
|
||||
return { row, kind: null, fn: null, error: result.reason === 'empty' ? null : result.reason }
|
||||
}),
|
||||
)
|
||||
|
||||
const series = computed<PlotSeries[]>(() => {
|
||||
const out: PlotSeries[] = []
|
||||
for (const e of entries.value) {
|
||||
if (!e.kind || !e.fn || !e.row.visible) continue
|
||||
// the union is keyed to the compile result's kind, which picks fn's shape
|
||||
if (e.kind === 'function')
|
||||
out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'function', fn: e.fn as PlotFunction })
|
||||
else out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'relation', fn: e.fn as PlaneFunction })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/* a new curve takes the first hue no other row is wearing */
|
||||
function nextColor(): string {
|
||||
const used = new Set(rows.value.map((r) => r.color))
|
||||
return PALETTE.find((c) => !used.has(c)) ?? PALETTE[rows.value.length % PALETTE.length]!
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
if (rows.value.length >= MAX_ROWS) return
|
||||
rows.value.push({ id: nextId++, expr: '', color: nextColor(), visible: true })
|
||||
}
|
||||
|
||||
function removeRow(id: number) {
|
||||
rows.value = rows.value.filter((r) => r.id !== id)
|
||||
}
|
||||
|
||||
function toggleVisible(id: number) {
|
||||
const row = rows.value.find((r) => r.id === id)
|
||||
if (row) row.visible = !row.visible
|
||||
}
|
||||
|
||||
function cycleColor(id: number) {
|
||||
const row = rows.value.find((r) => r.id === id)
|
||||
if (!row) return
|
||||
const at = PALETTE.indexOf(row.color)
|
||||
row.color = PALETTE[(at + 1) % PALETTE.length]!
|
||||
}
|
||||
|
||||
/* pressing Enter on the last row opens the next one, the way the list
|
||||
invites one more function */
|
||||
function onEnter(id: number) {
|
||||
if (id === rows.value[rows.value.length - 1]?.id) addRow()
|
||||
}
|
||||
|
||||
/* whether the dock stands open is the reader's own — the fold slides the
|
||||
rows away and leaves the plane alone; the choice is kept between visits */
|
||||
const dockOpen = ref(true)
|
||||
|
||||
function toggleDock() {
|
||||
dockOpen.value = !dockOpen.value
|
||||
try {
|
||||
localStorage.setItem(DOCK_KEY, dockOpen.value ? '1' : '0')
|
||||
} catch {
|
||||
/* private mode etc. — the fold just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
/* the rows keep themselves between visits */
|
||||
function saveRows() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify(rows.value.map(({ expr, color, visible }) => ({ expr, color, visible })))
|
||||
)
|
||||
} catch {
|
||||
/* private mode etc. — the rows just won't persist */
|
||||
}
|
||||
}
|
||||
watch(rows, saveRows, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
dockOpen.value = localStorage.getItem(DOCK_KEY) !== '0'
|
||||
} catch {
|
||||
/* storage unreadable — the dock stays open */
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const stored: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(stored)) return
|
||||
const restored: PlotRow[] = []
|
||||
for (const item of stored) {
|
||||
if (typeof item !== 'object' || item === null) continue
|
||||
const { expr, color, visible } = item as Partial<Record<'expr' | 'color' | 'visible', unknown>>
|
||||
restored.push({
|
||||
id: nextId++,
|
||||
expr: typeof expr === 'string' ? expr : '',
|
||||
color: typeof color === 'string' ? color : PALETTE[restored.length % PALETTE.length]!,
|
||||
visible: visible !== false,
|
||||
})
|
||||
}
|
||||
if (restored.length) rows.value = restored.slice(0, MAX_ROWS)
|
||||
} catch {
|
||||
/* storage unreadable — the default functions stay */
|
||||
}
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calc-stage">
|
||||
<PlotCanvas :series="series" />
|
||||
|
||||
<Transition name="calc-dock">
|
||||
<aside v-if="dockOpen" class="calc-dock" :aria-label="t('calc.functionsPanel')">
|
||||
<div class="calc-dock-head">
|
||||
<h2 class="calc-dock-title">{{ t('calc.panelHeading') }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-icon-btn"
|
||||
:title="t('calc.collapse')"
|
||||
:aria-label="t('calc.collapse')"
|
||||
@click="toggleDock()"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul class="calc-rows">
|
||||
<li v-for="e in entries" :key="e.row.id" class="calc-item" :class="{ invalid: !!e.error }">
|
||||
<div class="calc-row">
|
||||
<button
|
||||
type="button"
|
||||
class="calc-swatch"
|
||||
:style="{ background: e.row.color }"
|
||||
:title="t('calc.cycleColor')"
|
||||
:aria-label="t('calc.cycleColor')"
|
||||
@click="cycleColor(e.row.id)"
|
||||
/>
|
||||
<label class="calc-input-box" :class="{ relation: e.kind === 'relation' }">
|
||||
<span v-if="e.kind !== 'relation'" class="calc-y" aria-hidden="true">y=</span>
|
||||
<input
|
||||
v-model="e.row.expr"
|
||||
class="calc-input"
|
||||
:class="{ 'input-error': !!e.error }"
|
||||
type="text"
|
||||
:aria-label="t('calc.expressionAria')"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
autocapitalize="off"
|
||||
@keydown.enter="onEnter(e.row.id)"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-icon-btn"
|
||||
:title="e.row.visible ? t('calc.hide') : t('calc.show')"
|
||||
:aria-label="e.row.visible ? t('calc.hide') : t('calc.show')"
|
||||
:aria-pressed="String(e.row.visible)"
|
||||
@click="toggleVisible(e.row.id)"
|
||||
>
|
||||
<svg v-if="e.row.visible" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.5-6.5 10-6.5S22 12 22 12s-3.5 6.5-10 6.5S2 12 2 12z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 8 10 8a13.2 13.2 0 0 1-1.67 2.68" />
|
||||
<path d="M6.61 6.61A13.5 13.5 0 0 0 2 12s3.5 8 10 8a9.7 9.7 0 0 0 5.39-1.61" />
|
||||
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
||||
<path d="m2 2 20 20" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-icon-btn calc-remove"
|
||||
:title="t('calc.remove')"
|
||||
:aria-label="t('calc.remove')"
|
||||
@click="removeRow(e.row.id)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="e.error" class="calc-error">{{
|
||||
t(e.error === 'unknown' ? 'calc.errorUnknown' : 'calc.errorSyntax')
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button v-if="rows.length < MAX_ROWS" type="button" class="btn btn-ghost btn-sm calc-add" @click="addRow">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
{{ t('calc.add') }}
|
||||
</button>
|
||||
</aside>
|
||||
</Transition>
|
||||
|
||||
<button
|
||||
v-if="!dockOpen"
|
||||
type="button"
|
||||
class="calc-dock-open"
|
||||
:title="t('calc.expand')"
|
||||
:aria-label="t('calc.expand')"
|
||||
@click="toggleDock()"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
{{ t('calc.panelHeading') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
46
frontend/app/components/GraphStage.vue
Normal file
46
frontend/app/components/GraphStage.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import type { GraphEdgeDto, GraphNodeDto } from './GraphView.vue'
|
||||
|
||||
interface GraphDto {
|
||||
nodes: GraphNodeDto[]
|
||||
edges: GraphEdgeDto[]
|
||||
}
|
||||
|
||||
/* The link map itself: the whole surface, with its toolbar floating over
|
||||
it. The /graph address draws it as the page; the book lays this same
|
||||
surface on one of its pages (ToolPane), an instance beside the open
|
||||
articles. */
|
||||
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const { t } = useI18n()
|
||||
|
||||
const { data, status, error } = await useFetch<GraphDto>(`${api}/articles/graph`, {
|
||||
default: () => ({ nodes: [], edges: [] }),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="graph-stage">
|
||||
<div v-if="error" class="graph-state">
|
||||
<div class="banner">
|
||||
<span class="glyph">!</span>
|
||||
<span v-html="t('common.apiDown')"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GraphView
|
||||
v-else-if="status !== 'pending' && data.nodes.length"
|
||||
:nodes="data.nodes"
|
||||
:edges="data.edges"
|
||||
/>
|
||||
|
||||
<div v-else-if="status !== 'pending'" class="graph-state">
|
||||
<div class="empty">
|
||||
<div class="glyph">∅</div>
|
||||
<h3>{{ t('graph.emptyTitle') }}</h3>
|
||||
<p>{{ t('graph.emptyText') }}</p>
|
||||
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
/* The graphing calculator's plane: one canvas painted with a labelled grid
|
||||
and any number of y = f(x) curves, each evaluated once per pixel column.
|
||||
Drag to move the plane, the wheel to zoom — anchored at the cursor, the
|
||||
way the link map does it — the buttons to zoom from the middle, and a
|
||||
double-click to return to the default view. A curve breaks where the
|
||||
numbers break (NaN, ±∞) or where it jumps the canvas between two
|
||||
and any number of curves — each y = f(x) traced once per pixel column,
|
||||
each relation F(x, y) = 0 found by marching squares over a coarse grid
|
||||
(every cell whose corners straddle zero gets a segment, so "y^2 + x^2 = 1"
|
||||
draws its circle) — and each point "(x, y)" as a labelled dot. Drag to move
|
||||
the plane, the wheel to zoom — anchored at
|
||||
the cursor, the way the link map does it — the buttons to zoom from the
|
||||
middle, and a double-click to return to the default view. A curve breaks
|
||||
where the numbers break (NaN, ±∞) or where it jumps the canvas between two
|
||||
pixels: tan(x) keeps its gaps rather than a false connecting line.
|
||||
Grid, axes and labels follow the site theme, accent included. */
|
||||
|
||||
import type { PlotFunction } from '~/utils/plot-expression'
|
||||
import type { PlaneFunction, PlotFunction } from '~/utils/plot-expression'
|
||||
|
||||
export interface PlotSeries {
|
||||
id: number
|
||||
color: string
|
||||
visible: boolean
|
||||
fn: PlotFunction | null
|
||||
}
|
||||
export type PlotSeries =
|
||||
| { id: number; color: string; visible: boolean; kind: 'function'; fn: PlotFunction }
|
||||
| { id: number; color: string; visible: boolean; kind: 'relation'; fn: PlaneFunction }
|
||||
// a single dot at fixed coordinates, optionally with a label ("P")
|
||||
| { id: number; color: string; visible: boolean; kind: 'point'; x: number; y: number; label: string | null }
|
||||
|
||||
const props = defineProps<{ series: PlotSeries[] }>()
|
||||
|
||||
@@ -47,6 +49,7 @@ const colors = {
|
||||
gridMajor: '#cfd5e3',
|
||||
axis: '#8a93a8',
|
||||
tick: '#8a93a8',
|
||||
pointRing: '#ffffff',
|
||||
}
|
||||
function readColors() {
|
||||
const cs = getComputedStyle(document.documentElement)
|
||||
@@ -55,6 +58,7 @@ function readColors() {
|
||||
colors.gridMajor = at('--border-strong', colors.gridMajor)
|
||||
colors.axis = at('--faint', colors.axis)
|
||||
colors.tick = at('--faint', colors.tick)
|
||||
colors.pointRing = at('--surface', colors.pointRing)
|
||||
}
|
||||
|
||||
/* the spacing between labelled lines: a 1/2/5 × 10ⁿ step wide enough for
|
||||
@@ -83,6 +87,12 @@ function formatReadout(v: number): string {
|
||||
return s === '-0.0' || /^-0(\.0+)?$/.test(s) ? s.slice(1) : s
|
||||
}
|
||||
|
||||
/* a point's coordinate as read on the plane: whole numbers stand plain, the
|
||||
rest keep six significant figures with the trailing zeros trimmed */
|
||||
function formatCoord(v: number): string {
|
||||
return Number.isInteger(v) ? String(v) : String(Number(v.toPrecision(6)))
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const ctx = canvasEl.value?.getContext('2d')
|
||||
if (!ctx || width === 0 || height === 0) return
|
||||
@@ -173,13 +183,25 @@ function draw() {
|
||||
ctx.fillText('0', axisX - 5, axisY + 9)
|
||||
}
|
||||
|
||||
/* the curves last, one pixel column at a time */
|
||||
/* the curves last. A function of x is traced one pixel column at a time;
|
||||
a relation F(x, y) = 0 has no such column form, so it is found by
|
||||
marching squares over a coarse grid — every cell whose corners straddle
|
||||
zero earns a short segment, and together they draw the level curve; a
|
||||
point is a single labelled dot. */
|
||||
for (const s of props.series) {
|
||||
if (!s.visible || !s.fn) continue
|
||||
if (!s.visible) continue
|
||||
ctx.strokeStyle = s.color
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineCap = 'round'
|
||||
if (s.kind === 'relation') {
|
||||
drawRelation(ctx, s.fn)
|
||||
continue
|
||||
}
|
||||
if (s.kind === 'point') {
|
||||
drawPoint(ctx, s.x, s.y, s.color, s.label)
|
||||
continue
|
||||
}
|
||||
ctx.beginPath()
|
||||
let pen = false
|
||||
let prevPy = 0
|
||||
@@ -205,6 +227,122 @@ function draw() {
|
||||
}
|
||||
}
|
||||
|
||||
/* one point: a filled dot in the series colour, ringed in the surface tint so
|
||||
it stays legible over gridlines on paper and slate alike, and labelled with
|
||||
its name (when it has one) and its coordinates, in the same colour so it
|
||||
reads as belonging to that dot */
|
||||
function drawPoint(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
color: string,
|
||||
label: string | null,
|
||||
) {
|
||||
const px = toScreenX(x)
|
||||
const py = toScreenY(y)
|
||||
ctx.beginPath()
|
||||
ctx.arc(px, py, 4, 0, Math.PI * 2)
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.strokeStyle = colors.pointRing
|
||||
ctx.stroke()
|
||||
const coords = `(${formatCoord(x)}, ${formatCoord(y)})`
|
||||
ctx.font = "12px 'Computer Modern', Georgia, serif"
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'bottom'
|
||||
ctx.fillStyle = color
|
||||
ctx.fillText(label ? `${label} = ${coords}` : coords, px + 8, py - 6)
|
||||
}
|
||||
|
||||
/* one edge of a marching-squares cell: where the segment between two corners
|
||||
crosses zero, as a point in screen space — null when the two agree in
|
||||
sign (no crossing) or either is a bad number */
|
||||
function edgeCross(ax: number, ay: number, av: number, bx: number, by: number, bv: number) {
|
||||
if (!Number.isFinite(av) || !Number.isFinite(bv)) return null
|
||||
if (av >= 0 === bv >= 0) return null
|
||||
const t = av / (av - bv)
|
||||
return { x: ax + (bx - ax) * t, y: ay + (by - ay) * t }
|
||||
}
|
||||
|
||||
/* the pixel grid a relation is sampled on, and the largest |F| taken for a
|
||||
real value — past it a corner reads as a pole, not a crossing, so its cell
|
||||
is left blank rather than connected by a false line */
|
||||
const RELATION_CELL = 8
|
||||
const RELATION_MAG = 1e12
|
||||
|
||||
function drawRelation(ctx: CanvasRenderingContext2D, f: PlaneFunction) {
|
||||
const cols = Math.ceil(width / RELATION_CELL)
|
||||
const rows = Math.ceil(height / RELATION_CELL)
|
||||
const nx = cols + 1
|
||||
const sample = (px: number, py: number): number => {
|
||||
const v = f(toWorldX(px), toWorldY(py))
|
||||
return !Number.isFinite(v) || Math.abs(v) > RELATION_MAG ? NaN : v
|
||||
}
|
||||
// two rows of samples, kept and swapped as the sweep walks down the canvas
|
||||
let prev = new Float64Array(nx)
|
||||
let curr = new Float64Array(nx)
|
||||
for (let i = 0; i < nx; i++) prev[i] = sample(i * RELATION_CELL, 0)
|
||||
|
||||
ctx.beginPath()
|
||||
let drew = false
|
||||
for (let j = 0; j < rows; j++) {
|
||||
const y0 = j * RELATION_CELL
|
||||
const y1 = Math.min(y0 + RELATION_CELL, height)
|
||||
for (let i = 0; i < nx; i++) curr[i] = sample(i * RELATION_CELL, y1)
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const x0 = i * RELATION_CELL
|
||||
const x1 = Math.min(x0 + RELATION_CELL, width)
|
||||
const tl = prev[i]!
|
||||
const tr = prev[i + 1]!
|
||||
const bl = curr[i]!
|
||||
const br = curr[i + 1]!
|
||||
// a cell with any bad corner is a singularity's neighbourhood: skip it
|
||||
if (!Number.isFinite(tl) || !Number.isFinite(tr) || !Number.isFinite(bl) || !Number.isFinite(br))
|
||||
continue
|
||||
const top = edgeCross(x0, y0, tl, x1, y0, tr)
|
||||
const right = edgeCross(x1, y0, tr, x1, y1, br)
|
||||
const bottom = edgeCross(x1, y1, br, x0, y1, bl)
|
||||
const left = edgeCross(x0, y0, tl, x0, y1, bl)
|
||||
const idx = ((tl >= 0 ? 8 : 0) | (tr >= 0 ? 4 : 0) | (br >= 0 ? 2 : 0) | (bl >= 0 ? 1 : 0)) >>> 0
|
||||
if (idx === 0 || idx === 15) continue
|
||||
|
||||
const seg = (p: { x: number; y: number } | null, q: { x: number; y: number } | null) => {
|
||||
if (!p || !q) return
|
||||
ctx.moveTo(p.x, p.y)
|
||||
ctx.lineTo(q.x, q.y)
|
||||
drew = true
|
||||
}
|
||||
if (idx === 5 || idx === 10) {
|
||||
// saddle: four crossings, and the cell centre says how they pair up
|
||||
const mid = sample((x0 + x1) / 2, (y0 + y1) / 2)
|
||||
const centreUp = Number.isFinite(mid) ? mid >= 0 : tl >= 0
|
||||
if (idx === 5) {
|
||||
seg(top, centreUp ? left : right)
|
||||
seg(bottom, centreUp ? right : left)
|
||||
} else {
|
||||
seg(top, centreUp ? right : left)
|
||||
seg(bottom, centreUp ? left : right)
|
||||
}
|
||||
} else if (idx === 3 || idx === 12) {
|
||||
seg(left, right)
|
||||
} else if (idx === 6 || idx === 9) {
|
||||
seg(top, bottom)
|
||||
} else {
|
||||
// one corner cut off: exactly two edges cross, and they form the segment
|
||||
const pts = [top, right, bottom, left].filter(
|
||||
(p): p is { x: number; y: number } => p !== null,
|
||||
)
|
||||
if (pts.length >= 2) seg(pts[0]!, pts[1]!)
|
||||
}
|
||||
}
|
||||
const swap = prev
|
||||
prev = curr
|
||||
curr = swap
|
||||
}
|
||||
if (drew) ctx.stroke()
|
||||
}
|
||||
|
||||
/* paint on demand: one frame is scheduled whenever something moved */
|
||||
let scheduled = false
|
||||
function requestDraw() {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleMeta } from './ArticleCard.vue'
|
||||
import type { ToolPage } from '~/utils/routes'
|
||||
|
||||
/* Centered search popup: the sidebar and home search bars (or Ctrl/⌘+K,
|
||||
or "/" anywhere but a text field) open it, then it live-searches
|
||||
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes. */
|
||||
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes.
|
||||
The wiki's own pages — the link map and the graphing calculator — answer
|
||||
the search too, and ↵ on one lays that surface on the book beside the
|
||||
open articles rather than turning the page away to it. */
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -13,9 +17,20 @@ const { open, show, hide, toggle } = useSearch()
|
||||
const { hide: hideSettings } = useSettings()
|
||||
const { hide: hideProfile } = useProfileMenu()
|
||||
const { viewed } = useRecentlyViewed()
|
||||
const { openTool } = useArticleTabs()
|
||||
|
||||
const MAX_RESULTS = 8
|
||||
|
||||
/* the wiki's own pages join the search under their names and a few plain
|
||||
words for what they are; each answers at its own address */
|
||||
const PAGE_TOOLS: { tool: ToolPage; terms: string[] }[] = [
|
||||
{ tool: 'graph', terms: ['graph', 'map', 'link map', 'network', 'constellation'] },
|
||||
{
|
||||
tool: 'calculator',
|
||||
terms: ['calculator', 'calc', 'plot', 'plotter', 'graphing', 'plane'],
|
||||
},
|
||||
]
|
||||
|
||||
const q = ref('')
|
||||
const results = ref<ArticleMeta[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -24,6 +39,17 @@ const activeIndex = ref(0)
|
||||
|
||||
const trimmed = computed(() => q.value.trim())
|
||||
|
||||
/* which of the wiki's own pages the term names — matched against the words
|
||||
and the page's own name, either reading into the other, so "calc" leads
|
||||
to the Calculator as surely as the whole word does */
|
||||
const pageRows = computed(() => {
|
||||
const term = trimmed.value.toLowerCase()
|
||||
if (!term) return []
|
||||
return PAGE_TOOLS.filter(({ tool, terms }) =>
|
||||
terms.some((w) => w.includes(term) || term.includes(w)) || tool.includes(term)
|
||||
)
|
||||
})
|
||||
|
||||
/* --- focus: the field takes focus on open, focus returns on close --- */
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
let previouslyFocused: HTMLElement | null = null
|
||||
@@ -122,8 +148,19 @@ function openAllResults() {
|
||||
void router.push({ path: '/', query: { q: trimmed.value } })
|
||||
}
|
||||
|
||||
/* open one of the wiki's own pages: lay the page on the book first, so it
|
||||
arrives with the surface already holding it, then turn the desk to its
|
||||
address — where the page is already open, this simply turns back to it */
|
||||
function openPageTool(tool: ToolPage) {
|
||||
hide()
|
||||
openTool(tool)
|
||||
void router.push(toolHref(tool))
|
||||
}
|
||||
|
||||
/* ↑/↓ walk the page rows and the articles as one list — the wiki's own
|
||||
pages lead when they match */
|
||||
function onFieldKeydown(e: KeyboardEvent) {
|
||||
const n = results.value.length
|
||||
const n = pageRows.value.length + results.value.length
|
||||
if (e.key === 'ArrowDown' && n) {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value + 1) % n
|
||||
@@ -132,7 +169,12 @@ function onFieldKeydown(e: KeyboardEvent) {
|
||||
activeIndex.value = (activeIndex.value - 1 + n) % n
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const chosen = results.value[activeIndex.value] ?? results.value[0]
|
||||
const page = pageRows.value[activeIndex.value]
|
||||
if (page) {
|
||||
openPageTool(page.tool)
|
||||
return
|
||||
}
|
||||
const chosen = results.value[activeIndex.value - pageRows.value.length] ?? results.value[0]
|
||||
if (chosen) openArticle(chosen)
|
||||
else if (trimmed.value) openAllResults()
|
||||
}
|
||||
@@ -209,18 +251,52 @@ function highlight(text: string): Part[] {
|
||||
</div>
|
||||
|
||||
<div class="search-modal-body">
|
||||
<div v-if="failed" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
<span>{{ t('search.apiDown') }}</span>
|
||||
</div>
|
||||
|
||||
<ul v-else-if="results.length" class="search-results" role="listbox">
|
||||
<li v-for="(a, i) in results" :key="a._id" role="option" :aria-selected="i === activeIndex">
|
||||
<!-- one list, arrowed as one: the wiki's own pages lead when the
|
||||
term names them, the articles answer under them -->
|
||||
<ul v-if="pageRows.length || results.length" class="search-results" role="listbox">
|
||||
<li v-for="(p, i) in pageRows" :key="p.tool" role="option" :aria-selected="i === activeIndex">
|
||||
<NuxtLink
|
||||
:to="articleHref(a)"
|
||||
:to="toolHref(p.tool)"
|
||||
class="search-result"
|
||||
:class="{ active: i === activeIndex }"
|
||||
@mouseenter="activeIndex = i"
|
||||
@click.prevent="openPageTool(p.tool)"
|
||||
>
|
||||
<span class="sr-title">
|
||||
<span class="sr-page-glyph" aria-hidden="true">
|
||||
<svg v-if="p.tool === 'graph'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 3v17a1 1 0 0 0 1 1h17" />
|
||||
<path d="M4.5 16.5c2.6 0 3.4-10.5 6.8-10.5s3.9 7 8.2 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<template v-for="(p2, pi) in highlight(t(TOOL_LABEL_KEYS[p.tool]))" :key="pi">
|
||||
<mark v-if="p2.hit">{{ p2.text }}</mark>
|
||||
<template v-else>{{ p2.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
<span class="sr-meta">
|
||||
<span>{{ toolHref(p.tool) }}</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-for="(a, i) in results"
|
||||
:key="a._id"
|
||||
role="option"
|
||||
:aria-selected="i + pageRows.length === activeIndex"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="articleHref(a)"
|
||||
class="search-result"
|
||||
:class="{ active: i + pageRows.length === activeIndex }"
|
||||
@mouseenter="activeIndex = i + pageRows.length"
|
||||
@click.prevent="openArticle(a)"
|
||||
>
|
||||
<span class="sr-title" :lang="articleLanguage(a)">
|
||||
@@ -245,6 +321,11 @@ function highlight(text: string): Part[] {
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else-if="failed" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
<span>{{ t('search.apiDown') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading">
|
||||
<div v-for="i in 3" :key="i" class="search-skeleton" />
|
||||
</div>
|
||||
|
||||
85
frontend/app/components/ToolPane.vue
Normal file
85
frontend/app/components/ToolPane.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
/* One page of the open book that holds one of the wiki's own surfaces — the
|
||||
link map, the graphing calculator — instead of an article. The paper is the
|
||||
same as an article's page: a spine with the title standing on end and the ×
|
||||
that closes the page, click anywhere to turn back to it. What fills the
|
||||
page is not scrolling prose but the live surface, edge to edge — its own
|
||||
instance, kept warm beside the articles you are reading.
|
||||
|
||||
Everything but the spine of a page that isn't in view is `inert`: the map
|
||||
won't take drags and the calculator won't take typing until you turn to it. */
|
||||
import type { ToolPage } from '~/utils/routes'
|
||||
|
||||
const props = defineProps<{
|
||||
/** which of the wiki's own pages this page holds */
|
||||
tool: ToolPage
|
||||
index: number
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
activate: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const title = computed(() => t(TOOL_LABEL_KEYS[props.tool]))
|
||||
|
||||
function turnToThisPage() {
|
||||
if (!props.active) emit('activate')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="book-pane pane-tools"
|
||||
:class="{ active }"
|
||||
:data-tab="toolPageIdentity(tool)"
|
||||
:data-index="index"
|
||||
:style="{ '--i': index }"
|
||||
:aria-label="t('pane.aria', { title })"
|
||||
@click="turnToThisPage"
|
||||
>
|
||||
<div
|
||||
class="pane-spine"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
:aria-disabled="active"
|
||||
:title="t('pane.spineHint', { title })"
|
||||
@click.stop="turnToThisPage"
|
||||
@keydown.enter.prevent="turnToThisPage"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="spine-close"
|
||||
:aria-label="t('common.closeTab')"
|
||||
@click.stop="emit('close')"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- the surface's own mark, under the ×, so the spine reads as the
|
||||
page it holds even fanned out among articles -->
|
||||
<svg v-if="tool === 'graph'" class="spine-glyph" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
|
||||
</svg>
|
||||
<svg v-else class="spine-glyph" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 3v17a1 1 0 0 0 1 1h17" />
|
||||
<path d="M4.5 16.5c2.6 0 3.4-10.5 6.8-10.5s3.9 7 8.2 7" />
|
||||
</svg>
|
||||
<span class="spine-title">{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pane-body" :inert="active ? null : ''">
|
||||
<div class="pane-stage">
|
||||
<GraphStage v-if="tool === 'graph'" />
|
||||
<CalculatorStage v-else />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user