This commit is contained in:
10
AGENTS.md
10
AGENTS.md
@@ -220,7 +220,15 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
|
||||
- `app/utils/markdown.ts` — the single render pipeline: `markdown-it` (**html: false**,
|
||||
raw HTML is escaped) + `markdown-it-texmath` + KaTeX (`$...$` inline, `$$...$$` display,
|
||||
macros `\R \N \Z \Q \C`) + highlight.js, sanitized with DOMPurify. It also builds the
|
||||
TOC. Render all article Markdown through this, not ad-hoc renderers.
|
||||
TOC. Fenced blocks tagged ` ```example / theorem / corollary / definition / proof /
|
||||
proposition / lemma ` (optionally followed by a title) render as callout boxes —
|
||||
blockquote-styled, collapsible through a native `<details>` whose summary is the
|
||||
label row (always opens open; MarkdownView animates the fold with the Web
|
||||
Animations API — the intent rides on `data-open`, motion skipped under
|
||||
`prefers-reduced-motion`), each kind with its own hue (`--box-*` in
|
||||
`_tokens.scss`, shape `.md-box` in `_prose.scss`) and a label written in the
|
||||
*article's* language (`MarkdownView`'s `articleLang` → `renderMarkdown`'s env,
|
||||
never the UI locale). Render all article Markdown through this, not ad-hoc renderers.
|
||||
- API calls use `$fetch` against `${useRuntimeConfig().public.apiBase}/api`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
248
frontend/app/assets/css/_calculator.scss
Normal file
248
frontend/app/assets/css/_calculator.scss
Normal file
@@ -0,0 +1,248 @@
|
||||
@use 'mixins' as *;
|
||||
|
||||
// ---------- Graphing calculator (/calculator) ----------
|
||||
|
||||
// desktop: the plane owns the space beside the sidebar the way the link
|
||||
// map does — a full surface with the function dock and the zoom buttons
|
||||
// floating over it. (The page transition only fades, so no transform
|
||||
// steals the fixed positioning.)
|
||||
.calc-stage {
|
||||
position: fixed;
|
||||
inset: 0 0 0 var(--sidebar-w);
|
||||
transition: left 0.22s ease;
|
||||
z-index: 1;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
// the plane fills the stage
|
||||
.plot-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.plot-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none; // the plane owns drags and pinches, not the page
|
||||
}
|
||||
|
||||
// the function dock, pinned to the plane's left edge
|
||||
.calc-dock {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: clamp(260px, 24vw, 320px);
|
||||
padding: 18px 16px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.calc-dock-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.calc-dock-title {
|
||||
@include eyebrow(0.74rem, 750, 0.09em);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calc-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.calc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// the curve's hue; a press hands it on to the next one in the palette
|
||||
.calc-swatch {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid color-mix(in srgb, var(--text) 12%, transparent);
|
||||
transition: transform 0.15s;
|
||||
|
||||
&:hover { transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.calc-input-box {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--ring);
|
||||
}
|
||||
|
||||
.calc-item.invalid & {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
// the "y =" the field answers to, set like the math it introduces
|
||||
.calc-y {
|
||||
flex: none;
|
||||
padding: 0 2px 0 10px;
|
||||
color: var(--faint);
|
||||
font: italic 0.95rem/1 'Computer Modern', Georgia, serif;
|
||||
}
|
||||
|
||||
.calc-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 9px 10px 9px 6px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: 0.95rem/1.2 'Computer Modern', Georgia, serif;
|
||||
|
||||
&::placeholder { color: var(--faint); opacity: 0.75; }
|
||||
|
||||
&.input-error { color: var(--danger); }
|
||||
}
|
||||
|
||||
.calc-remove:hover {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 10%, transparent);
|
||||
}
|
||||
|
||||
.calc-error {
|
||||
display: block;
|
||||
margin: 5px 2px 1px;
|
||||
color: var(--danger);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.calc-add { margin-top: 12px; }
|
||||
|
||||
// the fold: the dock slides out to the left, under the app sidebar, and a
|
||||
// quiet card takes its place at the plane's top-left corner
|
||||
.calc-dock-enter-active,
|
||||
.calc-dock-leave-active { transition: transform 0.22s ease; }
|
||||
|
||||
.calc-dock-enter-from,
|
||||
.calc-dock-leave-to { transform: translateX(-105%); }
|
||||
|
||||
.calc-dock-open {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 11px;
|
||||
background: color-mix(in srgb, var(--bg-elev) 84%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--muted);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, background-color 0.15s;
|
||||
|
||||
&:hover { color: var(--text); background: var(--surface-2); }
|
||||
}
|
||||
|
||||
// the button row floating on the plane's top-right corner — clear of the dock
|
||||
.plot-toolbar {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 11px;
|
||||
background: color-mix(in srgb, var(--bg-elev) 84%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
// where the cursor stands, bottom-right so the dock never covers it
|
||||
.plot-readout {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 12px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-elev) 84%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--muted);
|
||||
font: 0.78rem/1.3 'Computer Modern', Georgia, serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// the top bar is in the flow here, so the calculator folds into panels of
|
||||
// the page: the dock first, then the plane as its own card
|
||||
@media (max-width: 900px) {
|
||||
.calc-stage {
|
||||
position: static;
|
||||
inset: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.calc-dock {
|
||||
position: static;
|
||||
order: 1;
|
||||
width: auto;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-elev);
|
||||
backdrop-filter: none;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.plot-wrap {
|
||||
position: static;
|
||||
order: 2;
|
||||
width: 100%;
|
||||
height: min(66dvh, 560px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.plot-toolbar { top: 8px; right: 8px; }
|
||||
|
||||
// during the fold the dock lifts out of the flow, so the plane takes the
|
||||
// width back at once rather than after the slide
|
||||
.calc-dock-leave-active { position: absolute; left: 0; right: 0; }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@use 'mixins' as *;
|
||||
|
||||
// ---------- Graph view (the link map) ----------
|
||||
|
||||
// desktop: the map owns the space beside the sidebar the way the open
|
||||
@@ -21,11 +23,16 @@
|
||||
touch-action: none; // the map owns drags and pinches, not the page
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
// top-right corner of the map: the button row, with the display menu
|
||||
// folding out under it
|
||||
.graph-tools {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 18px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
@@ -34,6 +41,53 @@
|
||||
background: color-mix(in srgb, var(--bg-elev) 84%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
// the display button while its menu is open, the ⋮ menu's way
|
||||
.panel-icon-btn.open { color: var(--text); background: var(--surface-2); }
|
||||
}
|
||||
|
||||
// the collapsible display menu, hinged under the buttons
|
||||
.graph-menu {
|
||||
@include dropdown-panel($z: 20, $overflow: visible);
|
||||
right: 0;
|
||||
min-width: 216px;
|
||||
|
||||
.graph-menu-row {
|
||||
@include menu-row(8px 10px, 14px);
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
|
||||
&:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||
}
|
||||
}
|
||||
|
||||
// the switch a menu row wears: a pill with a knob that rides across
|
||||
.graph-switch {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--border-strong);
|
||||
transition: background-color 0.15s;
|
||||
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-elev);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
&.on {
|
||||
background: var(--accent);
|
||||
|
||||
.knob { transform: translateX(14px); }
|
||||
}
|
||||
}
|
||||
|
||||
.graph-state {
|
||||
@@ -62,7 +116,7 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.graph-toolbar { top: 10px; right: 10px; }
|
||||
.graph-tools { top: 10px; right: 10px; }
|
||||
|
||||
.graph-state { position: static; display: block; padding: 8px 0 32px; }
|
||||
}
|
||||
|
||||
@@ -69,6 +69,58 @@
|
||||
p { margin: 0.2em 0; }
|
||||
}
|
||||
|
||||
// math callout boxes — ```theorem, ```example, … (see utils/markdown.ts):
|
||||
// the blockquote's shape, but the left edge wears the kind's colour, and
|
||||
// the label row is a native <details> toggle — the box folds away under it.
|
||||
.md-box {
|
||||
@each $kind in example theorem corollary definition proof proposition lemma {
|
||||
&.md-box--#{$kind} { --md-box: var(--box-#{$kind}); }
|
||||
}
|
||||
|
||||
border-left-color: var(--md-box);
|
||||
background: color-mix(in srgb, var(--md-box) 6%, transparent);
|
||||
|
||||
.md-box-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
margin: 0;
|
||||
color: var(--md-box);
|
||||
font-weight: 730;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
transition: margin-bottom 0.15s ease;
|
||||
|
||||
&::-webkit-details-marker { display: none; }
|
||||
|
||||
// the caret: points down while open, turns aside when the box folds
|
||||
&::before {
|
||||
content: '';
|
||||
flex: none;
|
||||
width: 0.42em;
|
||||
height: 0.42em;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
details[open] .md-box-title { margin-bottom: 0.4em; }
|
||||
|
||||
// the fold's intent lands on the <details> the moment it is clicked
|
||||
// (see MarkdownView): the label closes in and the caret turns at the
|
||||
// *start* of the fold, while [open] itself settles only as it ends
|
||||
details[data-open='0'] .md-box-title { margin-bottom: 0; }
|
||||
|
||||
details:not([open]) .md-box-title::before,
|
||||
details[data-open='0'] .md-box-title::before { transform: rotate(-45deg); }
|
||||
}
|
||||
|
||||
hr { border: 0; border-top: 1px solid var(--border); margin: 2.4em 0; }
|
||||
|
||||
table {
|
||||
|
||||
@@ -25,6 +25,16 @@
|
||||
--shadow-lg: 0 4px 12px rgb(16 24 40 / 8%), 0 16px 40px -12px rgb(16 24 40 / 18%);
|
||||
--ring: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
|
||||
// math callout boxes — ```theorem, ```example, … (see utils/markdown.ts):
|
||||
// each kind keeps one hue day and night, so the colour always means the same
|
||||
--box-example: #16a34a;
|
||||
--box-theorem: #d1344b;
|
||||
--box-corollary: #d97706;
|
||||
--box-definition: #2563eb;
|
||||
--box-proof: #64748b;
|
||||
--box-proposition: #7c3aed;
|
||||
--box-lemma: #0d9488;
|
||||
|
||||
// code token colors (light)
|
||||
--tok-key: #8250df;
|
||||
--tok-str: #0a3069;
|
||||
@@ -64,6 +74,15 @@
|
||||
--shadow-sm: 0 1px 2px rgb(0 0 0 / 30%);
|
||||
--shadow-lg: 0 4px 16px rgb(0 0 0 / 40%), 0 24px 48px -16px rgb(0 0 0 / 55%);
|
||||
|
||||
// math callout boxes (dark)
|
||||
--box-example: #4ade80;
|
||||
--box-theorem: #f47085;
|
||||
--box-corollary: #fbbf24;
|
||||
--box-definition: #60a5fa;
|
||||
--box-proof: #94a3b8;
|
||||
--box-proposition: #a78bfa;
|
||||
--box-lemma: #2dd4bf;
|
||||
|
||||
// code token colors (dark)
|
||||
--tok-key: #c792ea;
|
||||
--tok-str: #9ece6a;
|
||||
|
||||
@@ -26,4 +26,5 @@
|
||||
@use 'editor'; // editor, cheatsheet, confirm, fullscreen
|
||||
@use 'book'; // the reader: an open book of article pages
|
||||
@use 'graph'; // the link map
|
||||
@use 'calculator'; // the graphing calculator: function rows + the plane
|
||||
@use 'transitions'; // route fades, scrollbars
|
||||
|
||||
@@ -120,6 +120,13 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.graph') }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/calculator" class="nav-link" :title="t('sidebar.calculatorHint')">
|
||||
<svg width="14" height="14" 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="hide-collapsed">{{ t('sidebar.calculator') }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
|
||||
@@ -870,7 +870,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<div class="preview-label">{{ t('editor.livePreview') }}</div>
|
||||
<MarkdownView :content="previewContent || `*${t('editor.noPreview')}*`" />
|
||||
<MarkdownView :content="previewContent || `*${t('editor.noPreview')}*`" :article-lang="isVersioned ? activeLang : language" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -138,7 +138,7 @@ function topicLabel(tag: string) {
|
||||
</header>
|
||||
|
||||
<div class="article-body">
|
||||
<MarkdownView :content="article.content ?? ''" :namespace="slug" intercept-links />
|
||||
<MarkdownView :content="article.content ?? ''" :namespace="slug" :article-lang="language" intercept-links />
|
||||
</div>
|
||||
|
||||
<footer v-if="article.tags?.length" class="article-topics">
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
one article's text into another is an edge between them. A small
|
||||
hand-rolled force simulation (pairwise repulsion, springs along the
|
||||
edges, a gentle pull to the centre) settles the nodes into a shape,
|
||||
and everything is painted on one canvas: drag the background to pan,
|
||||
the wheel to zoom, a node to pull it around, and let go of a node
|
||||
where it was pressed to open that article. */
|
||||
and everything is painted on one canvas — each node wears the colour of
|
||||
its first topic, an article with no topics the theme accent. Drag the
|
||||
background to pan, the wheel to zoom, a node to pull it around, and let
|
||||
go of a node where it was pressed to open that article. */
|
||||
|
||||
export interface GraphNodeDto {
|
||||
slug: string
|
||||
@@ -28,12 +29,44 @@ const cursor = ref<'grab' | 'grabbing' | 'pointer'>('grab')
|
||||
const hoverTitle = ref('')
|
||||
const { t } = useI18n()
|
||||
|
||||
/* the display menu that folds out under the toolbar — one switch for now:
|
||||
whether nodes wear their topic's colour or all wear the accent. The
|
||||
choice is the reader's own, kept between visits. */
|
||||
const COLOR_KEY = 'mathew-graph-color'
|
||||
const menuOpen = ref(false)
|
||||
const colorNodes = ref(true)
|
||||
const toolsEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen.value = !menuOpen.value
|
||||
}
|
||||
|
||||
function toggleColorNodes() {
|
||||
colorNodes.value = !colorNodes.value
|
||||
try {
|
||||
localStorage.setItem(COLOR_KEY, colorNodes.value ? '1' : '0')
|
||||
} catch {
|
||||
/* private mode etc. — the choice just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
/* the menu folds away the way the app's other menus do: a click outside
|
||||
it (the canvas's own drags answer as clicks too), or esc */
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (menuOpen.value && !toolsEl.value?.contains(e.target as Node)) menuOpen.value = false
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && menuOpen.value) menuOpen.value = false
|
||||
}
|
||||
|
||||
/* the simulation is plain mutable state — it ticks every frame, and
|
||||
Vue would only slow it down */
|
||||
interface SimNode {
|
||||
slug: string
|
||||
title: string
|
||||
deg: number
|
||||
topic: string | null
|
||||
color: string
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
@@ -87,7 +120,6 @@ let downPt = { x: 0, y: 0 }
|
||||
const colors = {
|
||||
edge: '#888',
|
||||
accent: '#4f5fe8',
|
||||
accent2: '#0ea5c9',
|
||||
halo: '#ffffff',
|
||||
label: '#5b6478',
|
||||
text: '#1b2130',
|
||||
@@ -98,10 +130,36 @@ function readColors() {
|
||||
cs.getPropertyValue(name).trim() || fallback
|
||||
colors.edge = at('--border-strong', colors.edge)
|
||||
colors.accent = at('--accent', colors.accent)
|
||||
colors.accent2 = at('--accent-2', colors.accent2)
|
||||
colors.halo = at('--bg', colors.halo)
|
||||
colors.label = at('--muted', colors.label)
|
||||
colors.text = at('--text', colors.text)
|
||||
/* untopiced nodes wear the accent — recolour them when it moves */
|
||||
for (const n of simNodes) if (!n.topic) n.color = colors.accent
|
||||
}
|
||||
|
||||
/* a node wears the colour of the article's first topic: one categorical
|
||||
palette, each topic pinned to a slot by a hash of its name so the same
|
||||
topic keeps its colour across rebuilds. An article with no topics stays
|
||||
on the theme accent. */
|
||||
const PALETTE = [
|
||||
'#e5484d', '#f76808', '#ffb224', '#46a758', '#12a594',
|
||||
'#0091ff', '#8e4ec6', '#e93d82', '#946f4a', '#3e63dd',
|
||||
]
|
||||
const paletteSlot = new Map<string, string>()
|
||||
function topicColor(topic: string): string {
|
||||
let c = paletteSlot.get(topic)
|
||||
if (!c) {
|
||||
let h = 0
|
||||
for (let i = 0; i < topic.length; i++) h = (h * 31 + topic.charCodeAt(i)) >>> 0
|
||||
c = PALETTE[h % PALETTE.length]!
|
||||
paletteSlot.set(topic, c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
/* the first non-blank tag names the node's topic; one topic, one colour */
|
||||
function firstTopic(tags?: string[]): string | null {
|
||||
const tag = tags?.find((t) => t.trim() !== '')
|
||||
return tag ? tag.trim().toLowerCase() : null
|
||||
}
|
||||
|
||||
const toWorldX = (px: number) => (px - view.x) / view.k
|
||||
@@ -115,10 +173,13 @@ function build() {
|
||||
and the repulsion unfolds it into a disc from there */
|
||||
const angle = i * 2.399963229728653
|
||||
const radius = 26 * Math.sqrt(i + 1)
|
||||
const topic = firstTopic(n.tags)
|
||||
const node: SimNode = {
|
||||
slug: n.slug,
|
||||
title: n.title,
|
||||
deg: Math.max(n.links ?? 0, 0),
|
||||
topic,
|
||||
color: topic ? topicColor(topic) : colors.accent,
|
||||
x: radius * Math.cos(angle),
|
||||
y: radius * Math.sin(angle),
|
||||
vx: 0,
|
||||
@@ -254,8 +315,11 @@ function draw() {
|
||||
const dimmed = !!focus && !lit?.has(n.slug)
|
||||
const isFocus = n === focus
|
||||
|
||||
/* the topic's colour — or the plain accent when the reader has the
|
||||
colours switched off */
|
||||
const fill = colorNodes.value ? n.color : colors.accent
|
||||
ctx.globalAlpha = dimmed ? 0.16 : 1
|
||||
ctx.fillStyle = isFocus ? colors.accent2 : colors.accent
|
||||
ctx.fillStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
@@ -265,7 +329,7 @@ function draw() {
|
||||
ctx.stroke()
|
||||
if (isFocus) {
|
||||
ctx.globalAlpha = 0.35
|
||||
ctx.strokeStyle = colors.accent
|
||||
ctx.strokeStyle = fill
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, r + 4.5, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
@@ -482,6 +546,11 @@ let resizeObserver: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
colorNodes.value = localStorage.getItem(COLOR_KEY) !== '0'
|
||||
} catch {
|
||||
/* storage unreadable — keep the nodes coloured */
|
||||
}
|
||||
readColors()
|
||||
const wrap = wrapEl.value!
|
||||
const canvas = canvasEl.value!
|
||||
@@ -503,6 +572,8 @@ onMounted(() => {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme', 'data-accent'],
|
||||
})
|
||||
document.addEventListener('click', onDocClick)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
build()
|
||||
/* a few silent ticks so the first painted frame is already a shape */
|
||||
for (let i = 0; i < 40; i++) tick()
|
||||
@@ -514,6 +585,8 @@ onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(raf)
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
document.removeEventListener('click', onDocClick)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
/* a fresh fetch (a new article, a new link) rebuilds the map */
|
||||
@@ -544,6 +617,7 @@ watch(
|
||||
@dblclick="onDoubleClick"
|
||||
@pointerleave="clearHover"
|
||||
/>
|
||||
<div ref="toolsEl" class="graph-tools">
|
||||
<div class="graph-toolbar">
|
||||
<button type="button" class="panel-icon-btn" :title="t('graph.fit')" :aria-label="t('graph.fit')" @click="fitView">
|
||||
<svg 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">
|
||||
@@ -569,6 +643,39 @@ watch(
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-icon-btn"
|
||||
:class="{ open: menuOpen }"
|
||||
:title="t('graph.display')"
|
||||
:aria-label="t('graph.display')"
|
||||
:aria-expanded="String(menuOpen)"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<svg 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="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.6-.7 1.6-1.6 0-.4-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1a1.6 1.6 0 0 1 1.6-1.7H16c3.1 0 5.6-2.5 5.6-5.5C21.9 5.8 17.4 2 12 2Z" />
|
||||
<circle cx="7.5" cy="8" r=".5" />
|
||||
<circle cx="12" cy="5.5" r=".5" />
|
||||
<circle cx="16.5" cy="8.5" r=".5" />
|
||||
<circle cx="7" cy="12.5" r=".5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="menuOpen" class="graph-menu" role="group" :aria-label="t('graph.display')">
|
||||
<button
|
||||
type="button"
|
||||
class="graph-menu-row"
|
||||
role="switch"
|
||||
:aria-checked="String(colorNodes)"
|
||||
@click="toggleColorNodes"
|
||||
>
|
||||
<span>{{ t('graph.colorNodes') }}</span>
|
||||
<span class="graph-switch" :class="{ on: colorNodes }" aria-hidden="true">
|
||||
<span class="knob" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,15 +13,70 @@ const props = defineProps<{
|
||||
* keep two articles' "Proof" headings from sharing one id.
|
||||
*/
|
||||
namespace?: string
|
||||
/**
|
||||
* The article's own language: callout boxes (```theorem, …) label
|
||||
* themselves in it, whatever language the interface happens to speak.
|
||||
*/
|
||||
articleLang?: string
|
||||
}>()
|
||||
|
||||
const rendered = computed(() => renderMarkdown(props.content, props.namespace))
|
||||
const rendered = computed(() => renderMarkdown(props.content, props.namespace, props.articleLang))
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/* Callout boxes fold away under their label. The browser's <details> carries
|
||||
the state, but a state flip alone has no motion: the toggle click is caught
|
||||
here and the fold — height and opacity of the box's body — runs through the
|
||||
Web Animations API. Opening sets `open` right away (the body needs to be
|
||||
laid out before it can be measured); closing flips it only once the body
|
||||
has folded away. The box's intended state rides along as data-open, so
|
||||
re-clicking mid-fold picks the animation up where it stands instead of
|
||||
fighting it. */
|
||||
const folds = new WeakMap<HTMLDetailsElement, Animation>()
|
||||
|
||||
function foldBox(details: HTMLDetailsElement, event: MouseEvent) {
|
||||
const body = details.querySelector<HTMLElement>('.md-box-body')
|
||||
const isOpen = details.dataset.open ?? String(details.open)
|
||||
const wantOpen = isOpen !== 'true'
|
||||
details.dataset.open = String(wantOpen)
|
||||
|
||||
// nothing to shrink, or the reader asks for no motion: let <details> answer
|
||||
if (!body || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
|
||||
event.preventDefault()
|
||||
const running = folds.get(details)
|
||||
// a fold in flight shows its height — the new animation starts there
|
||||
const from = running ? getComputedStyle(body).height : wantOpen ? '0px' : `${body.scrollHeight}px`
|
||||
running?.cancel()
|
||||
if (wantOpen) details.open = true
|
||||
|
||||
body.style.overflow = 'hidden'
|
||||
const anim = body.animate(
|
||||
[
|
||||
{ height: from, opacity: wantOpen ? 0 : 1 },
|
||||
{ height: wantOpen ? `${body.scrollHeight}px` : '0px', opacity: wantOpen ? 1 : 0 },
|
||||
],
|
||||
{ duration: 260, easing: 'cubic-bezier(0.33, 1, 0.68, 1)' },
|
||||
)
|
||||
folds.set(details, anim)
|
||||
anim.onfinish = () => {
|
||||
details.open = wantOpen
|
||||
body.style.overflow = ''
|
||||
folds.delete(details)
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
if (!props.interceptLinks) return
|
||||
const target = e.target as HTMLElement
|
||||
const summary = target.closest?.('summary')
|
||||
if (summary) {
|
||||
const details = summary.closest('details')
|
||||
if (details) {
|
||||
foldBox(details, e)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!props.interceptLinks) return
|
||||
const anchor = target.closest?.('a')
|
||||
if (!anchor) return
|
||||
const href = anchor.getAttribute('href') || ''
|
||||
|
||||
372
frontend/app/components/PlotCanvas.vue
Normal file
372
frontend/app/components/PlotCanvas.vue
Normal file
@@ -0,0 +1,372 @@
|
||||
<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
|
||||
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'
|
||||
|
||||
export interface PlotSeries {
|
||||
id: number
|
||||
color: string
|
||||
visible: boolean
|
||||
fn: PlotFunction | null
|
||||
}
|
||||
|
||||
const props = defineProps<{ series: PlotSeries[] }>()
|
||||
|
||||
const wrapEl = ref<HTMLElement | null>(null)
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const cursor = ref<'grab' | 'grabbing'>('grab')
|
||||
const readout = ref('')
|
||||
const { t } = useI18n()
|
||||
|
||||
/* the view: the world point at the canvas's centre, and how many pixels one
|
||||
unit spans. Plain mutable state — it moves with every pan and zoom. */
|
||||
const view = { cx: 0, cy: 0, scale: 64 }
|
||||
const DEFAULT_SCALE = 64
|
||||
const MIN_SCALE = 1e-7
|
||||
const MAX_SCALE = 1e9
|
||||
|
||||
let width = 0
|
||||
let height = 0
|
||||
let dpr = 1
|
||||
|
||||
const toScreenX = (x: number) => (x - view.cx) * view.scale + width / 2
|
||||
const toScreenY = (y: number) => height / 2 - (y - view.cy) * view.scale
|
||||
const toWorldX = (px: number) => (px - width / 2) / view.scale + view.cx
|
||||
const toWorldY = (py: number) => (height / 2 - py) / view.scale + view.cy
|
||||
|
||||
/* canvas inks follow the site theme, including accent switches */
|
||||
const colors = {
|
||||
gridMinor: '#e2e6ef',
|
||||
gridMajor: '#cfd5e3',
|
||||
axis: '#8a93a8',
|
||||
tick: '#8a93a8',
|
||||
}
|
||||
function readColors() {
|
||||
const cs = getComputedStyle(document.documentElement)
|
||||
const at = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback
|
||||
colors.gridMinor = at('--border', colors.gridMinor)
|
||||
colors.gridMajor = at('--border-strong', colors.gridMajor)
|
||||
colors.axis = at('--faint', colors.axis)
|
||||
colors.tick = at('--faint', colors.tick)
|
||||
}
|
||||
|
||||
/* the spacing between labelled lines: a 1/2/5 × 10ⁿ step wide enough for
|
||||
about 80 pixels between them, so labels stay readable at every zoom */
|
||||
function niceStep() {
|
||||
const target = 80 / view.scale
|
||||
const pow = Math.pow(10, Math.floor(Math.log10(target)))
|
||||
const m = target / pow
|
||||
const mult = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10
|
||||
return mult * pow
|
||||
}
|
||||
|
||||
function formatTick(v: number, step: number): string {
|
||||
if (v === 0) return '0'
|
||||
if (step >= 1e6 || step < 1e-4) {
|
||||
const [mant, exp] = v.toExponential(2).split('e')
|
||||
return `${mant!.replace(/\.?0+$/, '')}e${exp}`
|
||||
}
|
||||
const decimals = Math.min(12, Math.max(0, -Math.floor(Math.log10(step))))
|
||||
return v.toFixed(decimals)
|
||||
}
|
||||
|
||||
function formatReadout(v: number): string {
|
||||
const decimals = Math.min(10, Math.max(2, Math.ceil(-Math.log10(view.scale)) + 3))
|
||||
const s = v.toFixed(decimals)
|
||||
return s === '-0.0' || /^-0(\.0+)?$/.test(s) ? s.slice(1) : s
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const ctx = canvasEl.value?.getContext('2d')
|
||||
if (!ctx || width === 0 || height === 0) return
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const step = niceStep()
|
||||
const minor = step / 5
|
||||
const x0 = toWorldX(0)
|
||||
const x1 = toWorldX(width)
|
||||
const yBottom = toWorldY(height)
|
||||
const yTop = toWorldY(0)
|
||||
|
||||
/* the faint minors, only while they still have room between them */
|
||||
if (minor * view.scale >= 7) {
|
||||
ctx.strokeStyle = colors.gridMinor
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
for (let k = Math.ceil(x0 / minor); k * minor <= x1; k++) {
|
||||
if (k % 5 === 0) continue // the majors are painted stronger below
|
||||
const px = Math.round(toScreenX(k * minor)) + 0.5
|
||||
ctx.moveTo(px, 0)
|
||||
ctx.lineTo(px, height)
|
||||
}
|
||||
for (let k = Math.ceil(yBottom / minor); k * minor <= yTop; k++) {
|
||||
if (k % 5 === 0) continue
|
||||
const py = Math.round(toScreenY(k * minor)) + 0.5
|
||||
ctx.moveTo(0, py)
|
||||
ctx.lineTo(width, py)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
/* the majors */
|
||||
ctx.strokeStyle = colors.gridMajor
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
for (let k = Math.ceil(x0 / step); k * step <= x1; k++) {
|
||||
if (k === 0) continue // the axis is its own line
|
||||
const px = Math.round(toScreenX(k * step)) + 0.5
|
||||
ctx.moveTo(px, 0)
|
||||
ctx.lineTo(px, height)
|
||||
}
|
||||
for (let k = Math.ceil(yBottom / step); k * step <= yTop; k++) {
|
||||
if (k === 0) continue
|
||||
const py = Math.round(toScreenY(k * step)) + 0.5
|
||||
ctx.moveTo(0, py)
|
||||
ctx.lineTo(width, py)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
/* the axes, once axes */
|
||||
const axisX = toScreenX(0)
|
||||
const axisY = toScreenY(0)
|
||||
ctx.strokeStyle = colors.axis
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.beginPath()
|
||||
if (axisY >= 0 && axisY <= height) {
|
||||
ctx.moveTo(0, Math.round(axisY) + 0.5)
|
||||
ctx.lineTo(width, Math.round(axisY) + 0.5)
|
||||
}
|
||||
if (axisX >= 0 && axisX <= width) {
|
||||
ctx.moveTo(Math.round(axisX) + 0.5, 0)
|
||||
ctx.lineTo(Math.round(axisX) + 0.5, height)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
/* the numbers: sitting on their axis, sliding to the canvas edge once the
|
||||
axis has been panned away, so the plane always reads */
|
||||
ctx.fillStyle = colors.tick
|
||||
ctx.font = "11px 'Computer Modern', Georgia, serif"
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'top'
|
||||
const labelY = Math.min(Math.max(axisY + 6, 6), height - 18)
|
||||
for (let k = Math.ceil(x0 / step); k * step <= x1; k++) {
|
||||
if (k === 0) continue
|
||||
ctx.fillText(formatTick(k * step, step), toScreenX(k * step), labelY)
|
||||
}
|
||||
ctx.textAlign = 'right'
|
||||
ctx.textBaseline = 'middle'
|
||||
const labelX = Math.min(Math.max(axisX - 8, 38), width - 6)
|
||||
for (let k = Math.ceil(yBottom / step); k * step <= yTop; k++) {
|
||||
if (k === 0) continue
|
||||
ctx.fillText(formatTick(k * step, step), labelX, toScreenY(k * step))
|
||||
}
|
||||
if (axisX >= 0 && axisX <= width && axisY >= 0 && axisY <= height) {
|
||||
ctx.fillText('0', axisX - 5, axisY + 9)
|
||||
}
|
||||
|
||||
/* the curves last, one pixel column at a time */
|
||||
for (const s of props.series) {
|
||||
if (!s.visible || !s.fn) continue
|
||||
ctx.strokeStyle = s.color
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineCap = 'round'
|
||||
ctx.beginPath()
|
||||
let pen = false
|
||||
let prevPy = 0
|
||||
for (let px = 0; px <= width; px++) {
|
||||
const y = s.fn(toWorldX(px))
|
||||
if (!Number.isFinite(y)) {
|
||||
pen = false
|
||||
continue
|
||||
}
|
||||
let py = toScreenY(y)
|
||||
// keep far-off coordinates sane for the canvas
|
||||
if (py < -1e4) py = -1e4
|
||||
else if (py > height + 1e4) py = height + 1e4
|
||||
// a jump of more than four canvases between adjacent pixels is an
|
||||
// asymptote, not a line — lift the pen instead of lying about it
|
||||
if (pen && Math.abs(py - prevPy) > height * 4) pen = false
|
||||
if (pen) ctx.lineTo(px, py)
|
||||
else ctx.moveTo(px, py)
|
||||
pen = true
|
||||
prevPy = py
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
/* paint on demand: one frame is scheduled whenever something moved */
|
||||
let scheduled = false
|
||||
function requestDraw() {
|
||||
if (scheduled || !import.meta.client) return
|
||||
scheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
scheduled = false
|
||||
draw()
|
||||
})
|
||||
}
|
||||
|
||||
function clampScale(k: number) {
|
||||
return Math.min(Math.max(k, MIN_SCALE), MAX_SCALE)
|
||||
}
|
||||
|
||||
function zoomAt(px: number, py: number, factor: number) {
|
||||
const k = clampScale(view.scale * factor)
|
||||
/* keep the world point under (px, py) anchored — the centre is recomputed
|
||||
against the new scale, so scale goes in last */
|
||||
const wx = toWorldX(px)
|
||||
const wy = toWorldY(py)
|
||||
view.scale = k
|
||||
view.cx = wx - (px - width / 2) / k
|
||||
view.cy = wy + (py - height / 2) / k
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
function zoomBy(factor: number) {
|
||||
zoomAt(width / 2, height / 2, factor)
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
view.cx = 0
|
||||
view.cy = 0
|
||||
view.scale = DEFAULT_SCALE
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
let panning = false
|
||||
let lastPt = { x: 0, y: 0 }
|
||||
|
||||
function localPoint(e: PointerEvent | WheelEvent) {
|
||||
const rect = canvasEl.value!.getBoundingClientRect()
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const p = localPoint(e)
|
||||
canvasEl.value?.setPointerCapture(e.pointerId)
|
||||
panning = true
|
||||
lastPt = p
|
||||
cursor.value = 'grabbing'
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
const p = localPoint(e)
|
||||
if (panning) {
|
||||
view.cx -= (p.x - lastPt.x) / view.scale
|
||||
view.cy += (p.y - lastPt.y) / view.scale
|
||||
lastPt = p
|
||||
requestDraw()
|
||||
} else {
|
||||
readout.value = t('calc.cursor', { x: formatReadout(toWorldX(p.x)), y: formatReadout(toWorldY(p.y)) })
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
panning = false
|
||||
cursor.value = 'grab'
|
||||
}
|
||||
|
||||
function clearHover() {
|
||||
if (panning) return
|
||||
readout.value = ''
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const p = localPoint(e)
|
||||
let dy = e.deltaY
|
||||
if (e.deltaMode === 1) dy *= 16
|
||||
else if (e.deltaMode === 2) dy *= 100
|
||||
/* pinch-to-zoom arrives as ctrl+wheel — calm the large deltas down */
|
||||
const step = e.ctrlKey ? 0.004 : 0.0016
|
||||
zoomAt(p.x, p.y, Math.exp(-dy * step))
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
readColors()
|
||||
const wrap = wrapEl.value!
|
||||
const canvas = canvasEl.value!
|
||||
|
||||
const resize = () => {
|
||||
const rect = wrap.getBoundingClientRect()
|
||||
width = Math.max(rect.width, 1)
|
||||
height = Math.max(rect.height, 1)
|
||||
dpr = window.devicePixelRatio || 1
|
||||
canvas.width = Math.round(width * dpr)
|
||||
canvas.height = Math.round(height * dpr)
|
||||
requestDraw()
|
||||
}
|
||||
resize()
|
||||
resizeObserver = new ResizeObserver(resize)
|
||||
resizeObserver.observe(wrap)
|
||||
|
||||
themeObserver = new MutationObserver(() => {
|
||||
readColors()
|
||||
requestDraw()
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme', 'data-accent'],
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
})
|
||||
|
||||
/* a new function, an edited one, a colour or visibility change — the plane
|
||||
is repainted */
|
||||
watch(() => props.series, requestDraw)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapEl" class="plot-wrap">
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
class="plot-canvas"
|
||||
:style="{ cursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@pointerleave="clearHover"
|
||||
@wheel="onWheel"
|
||||
@dblclick="resetView"
|
||||
/>
|
||||
<div class="plot-toolbar">
|
||||
<button type="button" class="panel-icon-btn" :title="t('calc.resetView')" :aria-label="t('calc.resetView')" @click="resetView">
|
||||
<svg 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="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M16 3h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M8 21H5a2 2 0 0 1-2-2v-3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="panel-icon-btn" :title="t('calc.zoomIn')" :aria-label="t('calc.zoomIn')" @click="zoomBy(1.35)">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="panel-icon-btn" :title="t('calc.zoomOut')" :aria-label="t('calc.zoomOut')" @click="zoomBy(1 / 1.35)">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="readout" class="plot-readout" aria-hidden="true">{{ readout }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -202,6 +202,7 @@ const stale = computed(() => {
|
||||
<MarkdownView
|
||||
:content="proposal.base.content || '*…*'"
|
||||
:namespace="`proposal-${proposal.id}-base`"
|
||||
:article-lang="proposal.proposed.language"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -218,6 +219,7 @@ const stale = computed(() => {
|
||||
<MarkdownView
|
||||
:content="proposal.proposed.content || '*…*'"
|
||||
:namespace="`proposal-${proposal.id}-proposed`"
|
||||
:article-lang="proposal.proposed.language"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
268
frontend/app/pages/calculator.vue
Normal file
268
frontend/app/pages/calculator.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<script setup lang="ts">
|
||||
import type { PlotFunction } from '~/utils/plot-expression'
|
||||
import type { PlotSeries } from '~/components/PlotCanvas.vue'
|
||||
|
||||
/* The graphing calculator: the plane owns the whole page, the way the link
|
||||
map does, and the y = f(x) inputs dock to its left edge — foldable, so
|
||||
the plane can have every pixel. Every expression is compiled as it is
|
||||
typed; one that does not read as a function of x goes red and simply
|
||||
stops being drawn. The rows and the dock's fold are the reader's own —
|
||||
kept between visits, the way the link map keeps its display switch. */
|
||||
|
||||
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
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 / 3 - 2', color: PALETTE[1]!, visible: true },
|
||||
]
|
||||
|
||||
const rows = ref<PlotRow[]>(seedRows())
|
||||
|
||||
/* one compile pass per render: the rows read alongside what they became —
|
||||
a function to plot, and a reason for the ones that read wrong (an empty
|
||||
field is nothing asked for, not a mistake) */
|
||||
interface PlotEntry {
|
||||
row: PlotRow
|
||||
fn: PlotFunction | null
|
||||
error: 'unknown' | 'syntax' | null
|
||||
}
|
||||
|
||||
const entries = computed<PlotEntry[]>(() =>
|
||||
rows.value.map((row) => {
|
||||
const src = row.expr.trim()
|
||||
if (!src) return { row, fn: null, error: null }
|
||||
const result = compilePlotExpression(src)
|
||||
return result.ok
|
||||
? { row, fn: result.fn, error: null }
|
||||
: { row, fn: null, error: result.reason === 'empty' ? null : result.reason }
|
||||
})
|
||||
)
|
||||
|
||||
const series = computed<PlotSeries[]>(() =>
|
||||
entries.value.map((e) => ({
|
||||
id: e.row.id,
|
||||
color: e.row.color,
|
||||
visible: e.row.visible && !!e.fn,
|
||||
fn: e.fn,
|
||||
}))
|
||||
)
|
||||
|
||||
/* 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 */
|
||||
}
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('calc.metaTitle'),
|
||||
description: () => t('calc.metaDescription'),
|
||||
})
|
||||
</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">
|
||||
<span class="calc-y" aria-hidden="true">y=</span>
|
||||
<input
|
||||
v-model="e.row.expr"
|
||||
class="calc-input"
|
||||
:class="{ 'input-error': !!e.error }"
|
||||
type="text"
|
||||
:placeholder="t('calc.placeholder')"
|
||||
: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>
|
||||
@@ -118,6 +118,76 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
|
||||
|
||||
md.inline.ruler.before('link', 'wikilink', wikilinkRule)
|
||||
|
||||
/**
|
||||
* Math callout blocks: a fenced block tagged with one of these keywords
|
||||
* (```theorem, ```example, ...) renders as a blockquote-styled box with the
|
||||
* keyword's colour on its left edge instead of as code. The body is parsed as
|
||||
* ordinary Markdown; any text after the keyword titles the box
|
||||
* (```theorem Pythagoras). Colours live in _tokens.scss, shapes in _prose.scss.
|
||||
* The label names the kind of statement *the article* makes, so it is written
|
||||
* in the article's language (carried through the render env) — the reader's
|
||||
* interface language never touches the page's text. Unknown languages keep English.
|
||||
*/
|
||||
type CalloutKind = 'example' | 'theorem' | 'corollary' | 'definition' | 'proof' | 'proposition' | 'lemma'
|
||||
|
||||
const CALLOUT_EN: Record<CalloutKind, string> = {
|
||||
example: 'Example',
|
||||
theorem: 'Theorem',
|
||||
corollary: 'Corollary',
|
||||
definition: 'Definition',
|
||||
proof: 'Proof',
|
||||
proposition: 'Proposition',
|
||||
lemma: 'Lemma',
|
||||
}
|
||||
|
||||
const CALLOUT_LABELS: Record<string, Partial<Record<CalloutKind, string>>> = {
|
||||
fr: { theorem: 'Théorème', corollary: 'Corollaire', definition: 'Définition', proof: 'Démonstration', example: 'Exemple', lemma: 'Lemme' },
|
||||
de: { theorem: 'Satz', corollary: 'Korollar', proof: 'Beweis', example: 'Beispiel' },
|
||||
es: { theorem: 'Teorema', corollary: 'Corolario', definition: 'Definición', proof: 'Demostración', proposition: 'Proposición', example: 'Ejemplo', lemma: 'Lema' },
|
||||
ca: { theorem: 'Teorema', corollary: 'Corol·lari', definition: 'Definició', proof: 'Demostració', proposition: 'Proposició', example: 'Exemple', lemma: 'Lema' },
|
||||
pt: { theorem: 'Teorema', corollary: 'Corolário', definition: 'Definição', proof: 'Demonstração', proposition: 'Proposição', example: 'Exemplo', lemma: 'Lema' },
|
||||
it: { theorem: 'Teorema', corollary: 'Corollario', definition: 'Definizione', proof: 'Dimostrazione', proposition: 'Proposizione', example: 'Esempio' },
|
||||
nl: { theorem: 'Stelling', corollary: 'Gevolg', proof: 'Bewijs', example: 'Voorbeeld', definition: 'Definitie', proposition: 'Propositie' },
|
||||
pl: { theorem: 'Twierdzenie', corollary: 'Wniosek', definition: 'Definicja', proof: 'Dowód', proposition: 'Propozycja', example: 'Przykład', lemma: 'Lemat' },
|
||||
ru: { theorem: 'Теорема', corollary: 'Следствие', definition: 'Определение', proof: 'Доказательство', proposition: 'Утверждение', example: 'Пример', lemma: 'Лемма' },
|
||||
uk: { theorem: 'Теорема', corollary: 'Наслідок', definition: 'Означення', proof: 'Доведення', proposition: 'Твердження', example: 'Приклад', lemma: 'Лема' },
|
||||
tr: { theorem: 'Teorem', corollary: 'Sonuç', definition: 'Tanım', proof: 'İspat', proposition: 'Önerme', example: 'Örnek' },
|
||||
ar: { theorem: 'مبرهنة', corollary: 'نتيجة', definition: 'تعريف', proof: 'برهان', proposition: 'قضية', example: 'مثال', lemma: 'ليما' },
|
||||
fa: { theorem: 'قضیه', corollary: 'نتیجه', definition: 'تعریف', proof: 'اثبات', proposition: 'گزاره', example: 'مثال', lemma: 'لم' },
|
||||
hi: { theorem: 'प्रमेय', corollary: 'निष्कर्ष', definition: 'परिभाषा', proof: 'प्रमाण', example: 'उदाहरण', proposition: 'कथन', lemma: 'उपप्रमेय' },
|
||||
zh: { theorem: '定理', corollary: '推论', definition: '定义', proof: '证明', proposition: '命题', lemma: '引理', example: '例' },
|
||||
ja: { theorem: '定理', corollary: '系', definition: '定義', proof: '証明', proposition: '命題', lemma: '補題', example: '例' },
|
||||
ko: { theorem: '정리', corollary: '계', definition: '정의', proof: '증명', proposition: '명제', lemma: '보조정리', example: '예' },
|
||||
}
|
||||
|
||||
function isCalloutWord(word: string): word is CalloutKind {
|
||||
return Object.hasOwn(CALLOUT_EN, word)
|
||||
}
|
||||
|
||||
const defaultFence = md.renderer.rules.fence!.bind(md.renderer)
|
||||
|
||||
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx]!
|
||||
const info = token.info.trim()
|
||||
const space = info.search(/\s/)
|
||||
const word = (space === -1 ? info : info.slice(0, space)).toLowerCase()
|
||||
if (!isCalloutWord(word)) return defaultFence(tokens, idx, options, env, self)
|
||||
|
||||
// renderMarkdown hands the article's language through the env; anything
|
||||
// rendered without one (or in a language not listed above) keeps English
|
||||
const lang = (env as { articleLanguage?: unknown } | undefined)?.articleLanguage
|
||||
const labels = (typeof lang === 'string' && CALLOUT_LABELS[lang.trim().toLowerCase()]) || CALLOUT_EN
|
||||
const kind = labels[word] ?? CALLOUT_EN[word]
|
||||
const title = space === -1 ? '' : info.slice(space + 1).trim()
|
||||
const label = title ? `${kind} — ${md.utils.escapeHtml(title)}` : kind
|
||||
const body = md.render(token.content, env).trim()
|
||||
// a native <details> makes the label row the toggle: it works on the first
|
||||
// paint and from the keyboard, and MarkdownView animates the fold; every
|
||||
// box starts open. The body is one wrapper so the fold has a single thing
|
||||
// to measure and shrink.
|
||||
return `<blockquote class="md-box md-box--${word}"><details open><summary class="md-box-title">${label}</summary><div class="md-box-body">${body}</div></details></blockquote>\n`
|
||||
}
|
||||
|
||||
/** Best-effort plain text of an inline token (for TOC labels). */
|
||||
function inlineText(inline: Token): string {
|
||||
let out = ''
|
||||
@@ -144,8 +214,11 @@ const sanitizeConfig = {
|
||||
* `namespace` prefixes the heading ids (the reader passes the article slug),
|
||||
* so that the articles open side by side never hand the document two
|
||||
* headings with the same id.
|
||||
*
|
||||
* `language` is the article's own language — callout boxes (```theorem, …)
|
||||
* write their labels in it, never in the interface language.
|
||||
*/
|
||||
export function renderMarkdown(source: string, namespace?: string): RenderedMarkdown {
|
||||
export function renderMarkdown(source: string, namespace?: string, language?: string): RenderedMarkdown {
|
||||
const src = source ?? ''
|
||||
const tokens = md.parse(src, {})
|
||||
const toc: TocEntry[] = []
|
||||
@@ -169,7 +242,7 @@ export function renderMarkdown(source: string, namespace?: string): RenderedMark
|
||||
if (level >= 2 && level <= 3) toc.push({ id: `${prefix}${id}`, text, level })
|
||||
}
|
||||
|
||||
const raw = md.renderer.render(tokens, md.options, {})
|
||||
const raw = md.renderer.render(tokens, md.options, { articleLanguage: language })
|
||||
const html = DOMPurify.sanitize(raw, sanitizeConfig) as unknown as string
|
||||
return { html, toc }
|
||||
}
|
||||
|
||||
308
frontend/app/utils/plot-expression.ts
Normal file
308
frontend/app/utils/plot-expression.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/* The graphing calculator's little algebra: a hand-rolled tokenizer and
|
||||
recursive-descent parser that reads an expression in x — "sin(x) + x / 4",
|
||||
"2^x", "x(x+1)" — and compiles it to a function the canvas evaluates pixel
|
||||
by pixel. No library: the grammar is small enough for one file, and the
|
||||
parser can be strict about what it promises — everything it accepts is a
|
||||
real function of x, and everything else is refused with a reason.
|
||||
|
||||
Precedence is the usual one: + - bind loosest; then * / % (left, implicit
|
||||
multiplication included — "2x" is 2·x); then unary minus, so -x^2 negates
|
||||
the square; then ^, right-associative, its exponent may carry a sign so
|
||||
2^-x reads. Functions call, constants stand, parentheses group. */
|
||||
|
||||
export type PlotFunction = (x: number) => number
|
||||
|
||||
/* why a text was refused: nothing to read, a name the calculator does not
|
||||
speak, or a shape the grammar rejects */
|
||||
export type PlotErrorReason = 'empty' | 'unknown' | 'syntax'
|
||||
|
||||
export type CompiledPlot =
|
||||
| { ok: true; fn: PlotFunction }
|
||||
| { ok: false; reason: PlotErrorReason }
|
||||
|
||||
type Op = '+' | '-' | '*' | '/' | '%' | '^' | '(' | ')' | ','
|
||||
|
||||
type Token = { t: Op } | { t: 'num'; value: number } | { t: 'id'; name: string }
|
||||
|
||||
type Node =
|
||||
| { kind: 'num'; value: number }
|
||||
| { kind: 'var' }
|
||||
| { kind: 'const'; value: number }
|
||||
| { kind: 'neg'; arg: Node }
|
||||
| { kind: 'bin'; op: '+' | '-' | '*' | '/' | '%' | '^'; a: Node; b: Node }
|
||||
| { kind: 'call'; name: string; args: Node[] }
|
||||
|
||||
/* the constants a name may stand for */
|
||||
const CONSTANTS: Record<string, number> = {
|
||||
pi: Math.PI,
|
||||
tau: Math.PI * 2,
|
||||
e: Math.E,
|
||||
phi: (1 + Math.sqrt(5)) / 2,
|
||||
}
|
||||
|
||||
/* the functions a name may call — all on the variable x, ln and log split
|
||||
the natural and the base-10 way as calculators write them */
|
||||
const FUNCTIONS: Record<string, (...args: number[]) => number> = {
|
||||
sin: Math.sin,
|
||||
cos: Math.cos,
|
||||
tan: Math.tan,
|
||||
asin: Math.asin,
|
||||
acos: Math.acos,
|
||||
atan: Math.atan,
|
||||
sinh: Math.sinh,
|
||||
cosh: Math.cosh,
|
||||
tanh: Math.tanh,
|
||||
ln: Math.log,
|
||||
log: Math.log10,
|
||||
log2: Math.log2,
|
||||
log10: Math.log10,
|
||||
sqrt: Math.sqrt,
|
||||
cbrt: Math.cbrt,
|
||||
abs: Math.abs,
|
||||
exp: Math.exp,
|
||||
floor: Math.floor,
|
||||
ceil: Math.ceil,
|
||||
round: Math.round,
|
||||
sign: Math.sign,
|
||||
min: (...a) => Math.min(...a),
|
||||
max: (...a) => Math.max(...a),
|
||||
mod: (a, b) => a % b,
|
||||
}
|
||||
|
||||
/* min and max take as many arguments as they are given; mod takes two;
|
||||
everything else wants exactly one */
|
||||
function arityOk(name: string, args: number): boolean {
|
||||
if (name === 'min' || name === 'max') return args >= 1
|
||||
if (name === 'mod') return args === 2
|
||||
return args === 1
|
||||
}
|
||||
|
||||
class PlotParseError extends Error {
|
||||
reason: Exclude<PlotErrorReason, 'empty'>
|
||||
constructor(reason: 'unknown' | 'syntax') {
|
||||
super(reason)
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
/* the input wears its "y =" beside the field; a reader who types it too is
|
||||
answered the same — the rest is what gets plotted. f(x) = reads too. */
|
||||
function stripAssignment(src: string): string {
|
||||
return src.replace(/^\s*(?:y|f\s*\(\s*x\s*\))\s*=/i, '')
|
||||
}
|
||||
|
||||
function tokenize(src: string): Token[] {
|
||||
/* the typographic shapes a reader may paste in stand for the plain ones */
|
||||
const s = src
|
||||
.replace(/[×·∗]/g, '*')
|
||||
.replace(/÷/g, '/')
|
||||
.replace(/[−–]/g, '-')
|
||||
.replace(/π/g, 'pi')
|
||||
.replace(/τ/g, 'tau')
|
||||
|
||||
const toks: Token[] = []
|
||||
let i = 0
|
||||
while (i < s.length) {
|
||||
const c = s[i]!
|
||||
if (c === ' ' || c === '\t' || c === '\n') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if ((c >= '0' && c <= '9') || c === '.') {
|
||||
const start = i
|
||||
let dot = false
|
||||
while (i < s.length) {
|
||||
const d = s[i]!
|
||||
if (d >= '0' && d <= '9') i++
|
||||
else if (d === '.' && !dot) {
|
||||
dot = true
|
||||
i++
|
||||
} else break
|
||||
}
|
||||
const value = Number.parseFloat(s.slice(start, i))
|
||||
if (!Number.isFinite(value)) throw new PlotParseError('syntax')
|
||||
toks.push({ t: 'num', value })
|
||||
continue
|
||||
}
|
||||
if (/[a-z_]/i.test(c)) {
|
||||
const start = i
|
||||
while (i < s.length && /[a-z0-9_]/i.test(s[i]!)) i++
|
||||
toks.push({ t: 'id', name: s.slice(start, i).toLowerCase() })
|
||||
continue
|
||||
}
|
||||
if (c === '*' && s[i + 1] === '*') {
|
||||
i += 2
|
||||
toks.push({ t: '^' }) // ** as an exponent, the programming shape
|
||||
continue
|
||||
}
|
||||
if (c === '+' || c === '-' || c === '*' || c === '/' || c === '%' || c === '^') {
|
||||
toks.push({ t: c as Op })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '(' || c === ')' || c === ',') {
|
||||
toks.push({ t: c as Op })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
throw new PlotParseError('syntax')
|
||||
}
|
||||
return toks
|
||||
}
|
||||
|
||||
interface Parser {
|
||||
toks: Token[]
|
||||
pos: number
|
||||
}
|
||||
|
||||
const peek = (p: Parser): Token | null => p.toks[p.pos] ?? null
|
||||
|
||||
const startsAtom = (tk: Token): boolean => tk.t === 'num' || tk.t === 'id' || tk.t === '('
|
||||
|
||||
function parseExpr(p: Parser): Node {
|
||||
let node = parseTerm(p)
|
||||
for (;;) {
|
||||
const tk = peek(p)
|
||||
if (!tk || (tk.t !== '+' && tk.t !== '-')) break
|
||||
p.pos++
|
||||
node = { kind: 'bin', op: tk.t, a: node, b: parseTerm(p) }
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function parseTerm(p: Parser): Node {
|
||||
let node = parseUnary(p)
|
||||
for (;;) {
|
||||
const tk = peek(p)
|
||||
if (!tk) break
|
||||
if (tk.t === '*' || tk.t === '/' || tk.t === '%') {
|
||||
p.pos++
|
||||
node = { kind: 'bin', op: tk.t, a: node, b: parseUnary(p) }
|
||||
continue
|
||||
}
|
||||
/* nothing between two factors and a factor following: that's a
|
||||
multiplication — 2x, 3(x+1), x sin(x) */
|
||||
if (startsAtom(tk)) {
|
||||
node = { kind: 'bin', op: '*', a: node, b: parseUnary(p) }
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function parseUnary(p: Parser): Node {
|
||||
const tk = peek(p)
|
||||
if (tk && tk.t === '+') {
|
||||
p.pos++
|
||||
return parseUnary(p)
|
||||
}
|
||||
if (tk && tk.t === '-') {
|
||||
p.pos++
|
||||
return { kind: 'neg', arg: parseUnary(p) }
|
||||
}
|
||||
return parsePower(p)
|
||||
}
|
||||
|
||||
function parsePower(p: Parser): Node {
|
||||
const base = parseAtom(p)
|
||||
const tk = peek(p)
|
||||
if (tk && tk.t === '^') {
|
||||
p.pos++
|
||||
// right-associative, and the exponent may carry its own sign
|
||||
return { kind: 'bin', op: '^', a: base, b: parseUnary(p) }
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function parseAtom(p: Parser): Node {
|
||||
const tk = peek(p)
|
||||
if (!tk) throw new PlotParseError('syntax')
|
||||
if (tk.t === 'num') {
|
||||
p.pos++
|
||||
return { kind: 'num', value: tk.value }
|
||||
}
|
||||
if (tk.t === '(') {
|
||||
p.pos++
|
||||
const inner = parseExpr(p)
|
||||
const close = peek(p)
|
||||
if (!close || close.t !== ')') throw new PlotParseError('syntax')
|
||||
p.pos++
|
||||
return inner
|
||||
}
|
||||
if (tk.t === 'id') {
|
||||
p.pos++
|
||||
if (tk.name === 'x') return { kind: 'var' }
|
||||
if (Object.hasOwn(CONSTANTS, tk.name)) return { kind: 'const', value: CONSTANTS[tk.name]! }
|
||||
if (Object.hasOwn(FUNCTIONS, tk.name)) {
|
||||
const open = peek(p)
|
||||
if (!open || open.t !== '(') throw new PlotParseError('syntax')
|
||||
p.pos++
|
||||
const args: Node[] = [parseExpr(p)]
|
||||
while (peek(p)?.t === ',') {
|
||||
p.pos++
|
||||
args.push(parseExpr(p))
|
||||
}
|
||||
const close = peek(p)
|
||||
if (!close || close.t !== ')') throw new PlotParseError('syntax')
|
||||
p.pos++
|
||||
if (!arityOk(tk.name, args.length)) throw new PlotParseError('syntax')
|
||||
return { kind: 'call', name: tk.name, args }
|
||||
}
|
||||
throw new PlotParseError('unknown')
|
||||
}
|
||||
throw new PlotParseError('syntax')
|
||||
}
|
||||
|
||||
function evaluate(node: Node, x: number): number {
|
||||
switch (node.kind) {
|
||||
case 'num':
|
||||
return node.value
|
||||
case 'var':
|
||||
return x
|
||||
case 'const':
|
||||
return node.value
|
||||
case 'neg':
|
||||
return -evaluate(node.arg, x)
|
||||
case 'call': {
|
||||
const fn = FUNCTIONS[node.name]!
|
||||
return fn(...node.args.map((a) => evaluate(a, x)))
|
||||
}
|
||||
case 'bin': {
|
||||
const a = evaluate(node.a, x)
|
||||
const b = evaluate(node.b, x)
|
||||
switch (node.op) {
|
||||
case '+':
|
||||
return a + b
|
||||
case '-':
|
||||
return a - b
|
||||
case '*':
|
||||
return a * b
|
||||
case '/':
|
||||
return a / b
|
||||
case '%':
|
||||
return a % b
|
||||
case '^':
|
||||
return Math.pow(a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* read one expression; a refusal says whether a name was not known or the
|
||||
shape was not a function — an empty text is the caller's to treat as
|
||||
"nothing asked for", not as an error */
|
||||
export function compilePlotExpression(src: string): CompiledPlot {
|
||||
if (src.length > 400) return { ok: false, reason: 'syntax' }
|
||||
try {
|
||||
const toks = tokenize(stripAssignment(src))
|
||||
if (toks.length === 0) return { ok: false, reason: 'empty' }
|
||||
const p: Parser = { toks, pos: 0 }
|
||||
const ast = parseExpr(p)
|
||||
if (p.pos !== toks.length) throw new PlotParseError('syntax')
|
||||
return { ok: true, fn: (x) => evaluate(ast, x) }
|
||||
} catch (err) {
|
||||
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
|
||||
return { ok: false, reason: 'syntax' }
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@
|
||||
"openTitle": "{n} article obert — ensenya'ls | {n} articles oberts — ensenya'ls",
|
||||
"articles": "Articles",
|
||||
"graph": "Graf",
|
||||
"calculator": "Calculadora",
|
||||
"calculatorHint": "Calculadora — representa funcions de x",
|
||||
"admin": "Admin",
|
||||
"adminHint": "Administració — els comptes d'aquesta wiki",
|
||||
"requests": "Sol·licituds",
|
||||
@@ -154,7 +156,30 @@
|
||||
"fit": "Ajusta el graf a la pantalla",
|
||||
"zoomIn": "Acosta",
|
||||
"zoomOut": "Allunya",
|
||||
"relayout": "Recalcula la disposició"
|
||||
"relayout": "Recalcula la disposició",
|
||||
"display": "Opcions de visualització",
|
||||
"colorNodes": "Acoloreix els nodes per tema"
|
||||
},
|
||||
"calc": {
|
||||
"metaTitle": "Calculadora gràfica",
|
||||
"metaDescription": "Representa funcions de x — el pla es mou arrossegant i s'apropa amb la roda. sin, cos, ln, sqrt i companyia.",
|
||||
"panelHeading": "Funcions",
|
||||
"functionsPanel": "Funcions a representar",
|
||||
"expressionAria": "Funció de x",
|
||||
"placeholder": "p. ex. sin(x) + x / 4",
|
||||
"cycleColor": "Canvia el color",
|
||||
"show": "Mostra al pla",
|
||||
"hide": "Amaga del pla",
|
||||
"remove": "Treu la funció",
|
||||
"add": "Afegeix funció",
|
||||
"errorUnknown": "No conec aquest nom — la calculadora entén x, pi, e i les funcions habituals.",
|
||||
"errorSyntax": "Això no es llegeix com una funció de x.",
|
||||
"resetView": "Reinicia la vista",
|
||||
"zoomIn": "Apropa",
|
||||
"zoomOut": "Allunya",
|
||||
"cursor": "x = {x} y = {y}",
|
||||
"collapse": "Amaga les funcions",
|
||||
"expand": "Mostra les funcions"
|
||||
},
|
||||
"reader": {
|
||||
"notFoundTitle": "Aquest article no existeix (encara)",
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
"openTitle": "{n} geöffneter Artikel — zeigen | {n} geöffnete Artikel — zeigen",
|
||||
"articles": "Artikel",
|
||||
"graph": "Graph",
|
||||
"calculator": "Rechner",
|
||||
"calculatorHint": "Rechner — Funktionen von x plotten",
|
||||
"admin": "Admin",
|
||||
"adminHint": "Verwaltung — die Konten in dieser Wiki",
|
||||
"requests": "Anfragen",
|
||||
@@ -154,7 +156,30 @@
|
||||
"fit": "Graph an den Bildschirm anpassen",
|
||||
"zoomIn": "Vergrößern",
|
||||
"zoomOut": "Verkleinern",
|
||||
"relayout": "Anordnung neu berechnen"
|
||||
"relayout": "Anordnung neu berechnen",
|
||||
"display": "Anzeigeoptionen",
|
||||
"colorNodes": "Knoten nach Thema färben"
|
||||
},
|
||||
"calc": {
|
||||
"metaTitle": "Grafikrechner",
|
||||
"metaDescription": "Funktionen von x zeichnen — die Ebene zieht man mit der Maus, die Rollen zoomt. sin, cos, ln, sqrt und Co.",
|
||||
"panelHeading": "Funktionen",
|
||||
"functionsPanel": "Zu zeichnende Funktionen",
|
||||
"expressionAria": "Funktion von x",
|
||||
"placeholder": "z. B. sin(x) + x / 4",
|
||||
"cycleColor": "Farbe wechseln",
|
||||
"show": "Auf der Ebene anzeigen",
|
||||
"hide": "Auf der Ebene ausblenden",
|
||||
"remove": "Funktion entfernen",
|
||||
"add": "Funktion hinzufügen",
|
||||
"errorUnknown": "Den Namen kenne ich nicht — der Rechner spricht x, pi, e und die üblichen Funktionen.",
|
||||
"errorSyntax": "Das liest sich nicht als Funktion von x.",
|
||||
"resetView": "Ansicht zurücksetzen",
|
||||
"zoomIn": "Vergrößern",
|
||||
"zoomOut": "Verkleinern",
|
||||
"cursor": "x = {x} y = {y}",
|
||||
"collapse": "Funktionen ausblenden",
|
||||
"expand": "Funktionen einblenden"
|
||||
},
|
||||
"reader": {
|
||||
"notFoundTitle": "Dieser Artikel existiert (noch) nicht",
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
"openTitle": "{n} open article — show them | {n} open articles — show them",
|
||||
"articles": "Articles",
|
||||
"graph": "Graph",
|
||||
"calculator": "Calculator",
|
||||
"calculatorHint": "Calculator — plot functions of x",
|
||||
"admin": "Admin",
|
||||
"adminHint": "Admin — the accounts on this wiki",
|
||||
"requests": "Requests",
|
||||
@@ -154,7 +156,30 @@
|
||||
"fit": "Fit the graph to the screen",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"relayout": "Re-run the layout"
|
||||
"relayout": "Re-run the layout",
|
||||
"display": "Display options",
|
||||
"colorNodes": "Colour nodes by topic"
|
||||
},
|
||||
"calc": {
|
||||
"metaTitle": "Graphing calculator",
|
||||
"metaDescription": "Plot functions of x — the plane moves with a drag and zooms with the wheel. sin, cos, ln, sqrt and friends.",
|
||||
"panelHeading": "Functions",
|
||||
"functionsPanel": "Functions to plot",
|
||||
"expressionAria": "Function of x",
|
||||
"placeholder": "e.g. sin(x) + x / 4",
|
||||
"cycleColor": "Change colour",
|
||||
"show": "Show on the plane",
|
||||
"hide": "Hide from the plane",
|
||||
"remove": "Remove function",
|
||||
"add": "Add function",
|
||||
"errorUnknown": "I don't know that name — the calculator speaks x, pi, e and the usual functions.",
|
||||
"errorSyntax": "This does not read as a function of x.",
|
||||
"resetView": "Reset the view",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"cursor": "x = {x} y = {y}",
|
||||
"collapse": "Hide functions",
|
||||
"expand": "Show functions"
|
||||
},
|
||||
"reader": {
|
||||
"notFoundTitle": "This article does not exist (yet)",
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
"openTitle": "{n} artículo abierto — mostrarlos | {n} artículos abiertos — mostrarlos",
|
||||
"articles": "Artículos",
|
||||
"graph": "Grafo",
|
||||
"calculator": "Calculadora",
|
||||
"calculatorHint": "Calculadora — representa funciones de x",
|
||||
"admin": "Admin",
|
||||
"adminHint": "Administración — las cuentas de esta wiki",
|
||||
"requests": "Solicitudes",
|
||||
@@ -154,7 +156,30 @@
|
||||
"fit": "Ajustar el grafo a la pantalla",
|
||||
"zoomIn": "Acercar",
|
||||
"zoomOut": "Alejar",
|
||||
"relayout": "Recalcular la disposición"
|
||||
"relayout": "Recalcular la disposición",
|
||||
"display": "Opciones de visualización",
|
||||
"colorNodes": "Colorear los nodos por tema"
|
||||
},
|
||||
"calc": {
|
||||
"metaTitle": "Calculadora gráfica",
|
||||
"metaDescription": "Representa funciones de x — el plano se mueve arrastrando y se acerca con la rueda. sin, cos, ln, sqrt y compañía.",
|
||||
"panelHeading": "Funciones",
|
||||
"functionsPanel": "Funciones a representar",
|
||||
"expressionAria": "Función de x",
|
||||
"placeholder": "p. ej. sin(x) + x / 4",
|
||||
"cycleColor": "Cambiar color",
|
||||
"show": "Mostrar en el plano",
|
||||
"hide": "Ocultar del plano",
|
||||
"remove": "Quitar función",
|
||||
"add": "Añadir función",
|
||||
"errorUnknown": "No conozco ese nombre — la calculadora habla x, pi, e y las funciones de siempre.",
|
||||
"errorSyntax": "Esto no se lee como una función de x.",
|
||||
"resetView": "Reiniciar la vista",
|
||||
"zoomIn": "Acercar",
|
||||
"zoomOut": "Alejar",
|
||||
"cursor": "x = {x} y = {y}",
|
||||
"collapse": "Ocultar las funciones",
|
||||
"expand": "Mostrar las funciones"
|
||||
},
|
||||
"reader": {
|
||||
"notFoundTitle": "Este artículo no existe (todavía)",
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
"openTitle": "{n} article ouvert — les afficher | {n} articles ouverts — les afficher",
|
||||
"articles": "Articles",
|
||||
"graph": "Graphe",
|
||||
"calculator": "Calculatrice",
|
||||
"calculatorHint": "Calculatrice — trace des fonctions de x",
|
||||
"admin": "Admin",
|
||||
"adminHint": "Administration — les comptes de ce wiki",
|
||||
"requests": "Demandes",
|
||||
@@ -154,7 +156,30 @@
|
||||
"fit": "Ajuster le graphe à l'écran",
|
||||
"zoomIn": "Zoomer",
|
||||
"zoomOut": "Dézoomer",
|
||||
"relayout": "Recalculer la disposition"
|
||||
"relayout": "Recalculer la disposition",
|
||||
"display": "Options d'affichage",
|
||||
"colorNodes": "Colorer les nœuds par thème"
|
||||
},
|
||||
"calc": {
|
||||
"metaTitle": "Calculatrice graphique",
|
||||
"metaDescription": "Tracez des fonctions de x — le plan se déplace à la souris et se zoome à la molette. sin, cos, ln, sqrt et compagnie.",
|
||||
"panelHeading": "Fonctions",
|
||||
"functionsPanel": "Fonctions à tracer",
|
||||
"expressionAria": "Fonction de x",
|
||||
"placeholder": "p. ex. sin(x) + x / 4",
|
||||
"cycleColor": "Changer de couleur",
|
||||
"show": "Afficher sur le plan",
|
||||
"hide": "Masquer du plan",
|
||||
"remove": "Supprimer la fonction",
|
||||
"add": "Ajouter une fonction",
|
||||
"errorUnknown": "Je ne connais pas ce nom — la calculatrice parle x, pi, e et les fonctions habituelles.",
|
||||
"errorSyntax": "Cela ne se lit pas comme une fonction de x.",
|
||||
"resetView": "Réinitialiser la vue",
|
||||
"zoomIn": "Zoomer",
|
||||
"zoomOut": "Dézoomer",
|
||||
"cursor": "x = {x} y = {y}",
|
||||
"collapse": "Masquer les fonctions",
|
||||
"expand": "Afficher les fonctions"
|
||||
},
|
||||
"reader": {
|
||||
"notFoundTitle": "Cet article n'existe pas (encore)",
|
||||
|
||||
Reference in New Issue
Block a user