Ai slop
Some checks failed
Build and Deploy Nuxt / build (push) Failing after 41s

This commit is contained in:
2026-10-03 14:13:08 +02:00
parent 68b74eb60b
commit 6df7950161
45 changed files with 4594 additions and 125 deletions

View File

@@ -273,12 +273,12 @@
// The link map and the graphing calculator are pages too: the search popup
// lays one of them on the book beside the articles (ToolPane.vue). It fills
// the page edge to edge — no column of prose scrolls here, the surface keeps
// its own docks and toolbars — and it takes the whole share of the desk, a
// plane being no narrower read than a page of text. Each stage's own sheet
// answers for the rest (_graph.scss, _calculator.scss).
// its own docks and toolbars — and it is held to the very width an article
// page is (the .book-pane rules: --pane-max-w, and the whole desk when it is
// the only page open). Each stage's own sheet answers for the rest
// (_graph.scss, _calculator.scss).
.book-pane.pane-tools {
max-width: none;
overflow: hidden;
}

View File

@@ -115,8 +115,9 @@
font: italic 0.95rem/1 'Computer Modern', Georgia, serif;
}
// a relation or point carries no lone "y =", so it sheds the gutter and the
// input takes back the room it stood in
// only a field that reads as a function wears the "y =" gutter; everything
// else — a relation, point, slider, or unfinished text — is bare, and the
// input takes back the room the gutter stood in
.calc-input-box.bare .calc-input {
padding-left: 10px;
}
@@ -134,6 +135,77 @@
&.input-error { color: var(--danger); }
}
// The typeset echo of the field's text (Desmos' way): while the reader
// types, the formula sets on its own line under the text; once the field
// rests, it takes the text's place beside the "y =" gutter, so the dock
// reads as mathematics. A click wakes the text again (the box is a label);
// the input only steps out of sight, never out of the tab order — and
// Tab waking it draws the text back. An expression that does not read
// carries no echo, so it stays plain text and red, as ever.
.calc-typeset {
display: none;
color: var(--text);
.katex { font-size: 1rem; }
}
.calc-input-box.typeset {
flex-wrap: wrap;
.calc-typeset { display: block; }
// typing: the echo takes a line of its own, under the field
&:focus-within .calc-typeset {
flex: 0 0 100%;
padding: 0 10px 8px;
}
// at rest: the field folds away and the formula stands in its place
&:not(:focus-within) {
.calc-input {
flex: none;
width: 0;
min-width: 0;
padding: 0;
opacity: 0;
}
.calc-typeset {
flex: 1;
min-width: 0;
padding: 8px 10px;
overflow-x: auto;
}
}
}
// A row that declared a variable ("k", "k = 3"). Once the reader stops
// editing it, the field folds away to nothing — the name and value now live
// in the slider laid in its place (see .calc-vars.is-inline). The input stays
// mounted and focusable (never display:none), so the slider's name can be a
// label that wakes it back to edit the declaration; a focused field stands
// open as any other. This is how a made variable reads as just a slider, not
// a "k = 3" box above a slider.
.calc-input-box.var-decl:not(:focus-within) {
flex: 0 0 0;
min-width: 0;
overflow: hidden;
border-color: transparent;
background: transparent;
.calc-input {
width: 0;
min-width: 0;
padding: 0;
}
}
// while the declaration's text is awake, its inline slider steps aside — the
// field is the whole row again
.calc-input-box.var-decl:focus-within ~ .calc-vars.is-inline {
display: none;
}
.calc-remove:hover {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, transparent);
@@ -148,6 +220,86 @@
.calc-add { margin-top: 12px; }
// free variables ("k", "a", "radius") — each slider hangs just under the
// row that claims it. The name sets like the maths it names; the drag does
// the rough work, the field the fine
.calc-vars {
display: flex;
flex-direction: column;
gap: 4px;
margin: 5px 0 2px;
padding: 0;
list-style: none;
}
.calc-var {
display: flex;
align-items: center;
gap: 8px;
}
.calc-var-name {
flex: none;
min-width: 1.5ch;
color: var(--text);
font: italic 0.98rem/1 'Computer Modern', Georgia, serif;
}
// the name of a made variable is the door back to its declaration: it is a
// label for the folded field, so clicking it wakes "k = 3" to be edited
.calc-var-name.editable {
padding: 1px 2px;
border-radius: 4px;
cursor: text;
&:hover { background: var(--surface-2); }
}
// a made variable's slider laid in the row in place of its field: no hanging
// margin, the drag takes the room the field used to hold
.calc-vars.is-inline {
flex: 1;
min-width: 0;
margin: 0;
gap: 0;
}
.calc-var-range {
flex: 1;
min-width: 0;
accent-color: var(--accent);
}
.calc-var-number {
flex: none;
width: 58px;
min-width: 0;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-elev);
color: var(--text);
font: 0.8rem/1.3 'Computer Modern', Georgia, serif;
font-variant-numeric: tabular-nums;
// no spinners on the slider's little twin — the drag is the spinner
-moz-appearance: textfield;
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
&:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--ring);
}
}
// 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,
@@ -195,6 +347,131 @@
box-shadow: var(--shadow-sm);
}
// the 3D scene hangs its toolbar from a wrapper so the configuration
// card can fold out under it: the wrapper takes the corner, the row
// keeps its look on the hinge
.plot-tools {
position: absolute;
top: 10px;
right: 12px;
z-index: 2;
.plot-toolbar { position: static; }
// the configuration button while its card is open, the graph menu's way
.panel-icon-btn.open { color: var(--text); background: var(--surface-2); }
}
// the configuration card: the space the surfaces stand in — each edge
// editable, the height by default the surfaces' own, the mesh's fineness
.plot-config {
@include dropdown-panel($z: 20, $overflow: visible);
right: 0;
min-width: 250px;
padding: 10px 12px;
}
.plot-config-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 18px;
padding: 3px 0;
}
.plot-config-axis {
color: var(--muted);
font: italic 13px 'Computer Modern', Georgia, serif;
}
.plot-config-label {
color: var(--muted);
font-size: 0.8rem;
font-weight: 600;
}
.plot-config-value {
color: var(--text);
font: 0.82rem/1.3 'Computer Modern', Georgia, serif;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.plot-config-bounds {
display: flex;
align-items: center;
gap: 6px;
}
.plot-config-dots {
color: var(--faint);
}
.plot-config-input {
width: 68px;
min-width: 0;
padding: 4px 7px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--surface);
color: var(--text);
font: 0.82rem/1.2 'Computer Modern', Georgia, serif;
font-variant-numeric: tabular-nums;
&:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--ring);
}
}
// the switch that decides whose height z answers to: the surfaces' or
// the reader's own two bounds
.plot-config-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
margin-top: 7px;
padding: 7px 0 5px;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.74rem;
line-height: 1.35;
text-align: left;
&:hover { color: var(--text); }
}
.plot-config-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); }
}
}
// where the cursor stands, bottom-right so the dock never covers it
.plot-readout {
position: absolute;
@@ -246,6 +523,8 @@
.plot-toolbar { top: 8px; right: 8px; }
.plot-tools { 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; }

View File

@@ -0,0 +1,271 @@
@use 'mixins' as *;
// ---------- Lean editor (/lean) ----------
// The wiki's own Lean workbench. On the /lean address it owns the space
// beside the sidebar the way the map and the plane do — fixed, edge to
// edge. In a book pane it becomes the pane's own box instead.
// (The page transition only fades, so no transform steals the
// fixed positioning.)
.lean-stage {
position: fixed;
inset: 0 0 0 var(--sidebar-w);
transition: left 0.22s ease;
z-index: 1;
background: var(--bg);
}
.pane-stage .lean-stage {
position: absolute;
inset: 0;
}
// editor left, infoview right — the workbench of every Lean editor, in
// the wiki's own materials. The fold closes the panel's track and the
// editor takes the whole width.
.lean-work {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
transition: grid-template-columns 0.22s ease;
}
.lean-work[data-panel='collapsed'] {
grid-template-columns: minmax(0, 1fr) 0;
}
.lean-work-editor {
position: relative;
min-width: 0;
background: var(--surface);
}
.lean-work-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
border-left: 1px solid var(--border);
background: var(--bg-elev);
overflow: hidden;
visibility: visible;
transition: opacity 0.18s ease, visibility 0s;
}
.lean-work[data-panel='collapsed'] .lean-work-panel {
opacity: 0;
visibility: hidden;
// out of sight and out of the reader's way — only after the track closes
transition: opacity 0.18s ease, visibility 0s 0.22s;
}
// status row: whose Lean, doing what, and the door to the classic client
.lean-work-status {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
border-bottom: 1px solid var(--border);
font-size: 0.74rem;
color: var(--muted);
.lean-work-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--faint);
flex-shrink: 0;
}
&[data-phase='ready'] .lean-work-dot { background: var(--success); }
&[data-phase='lost'] .lean-work-dot { background: var(--danger); }
&[data-phase='connecting'] .lean-work-dot,
&[data-phase='initializing'] .lean-work-dot { background: var(--accent); }
// elaborating: the amber pulse every Lean editor wears while working
&[data-busy='true'] .lean-work-dot {
background: #d97706;
animation: lean-pulse 1s ease-in-out infinite;
}
[data-theme='dark'] &[data-busy='true'] .lean-work-dot { background: #f5a83c; }
}
@keyframes lean-pulse {
50% { opacity: 0.35; }
}
.lean-work-phase {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// the status row's two quiet doors: the classic client outside, and the fold
.lean-work-classic,
.lean-work-collapse {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 7px;
color: var(--faint);
transition: color 0.15s ease, background 0.15s ease;
&:hover {
color: var(--text);
background: var(--surface-2);
}
}
.lean-work-classic {
margin-left: auto;
}
// the panes below: Goals and Messages, the wiki's own infoview
.lean-work-goals,
.lean-work-messages {
padding: 10px 12px 14px;
overflow-y: auto;
min-height: 0;
h3 {
margin: 0 0 8px;
font-size: 0.66rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--faint);
}
pre {
margin: 0 0 8px;
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--text);
font-family: ui-monospace, 'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace;
font-size: 0.78rem;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
}
.lean-work-goals { flex: 1 1 55%; }
.lean-work-messages {
flex: 1 1 45%;
border-top: 1px solid var(--border);
}
.lean-work-messages ul {
list-style: none;
margin: 0;
padding: 0;
li { position: relative; }
}
.lean-work-msg-line {
font-family: ui-monospace, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 0.66rem;
color: var(--faint);
}
.lean-work-none {
margin: 0;
font-size: 0.8rem;
color: var(--faint);
}
// the fold's other half: while the panel lies collapsed, this quiet card
// waits at the editor's top-right corner to pull the infoview back
.lean-panel-open {
position: absolute;
top: 10px;
right: 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); }
}
// until Lean answers — and when the server is not there at all — the
// wiki speaks for the editor
.lean-state {
position: absolute;
inset: 0;
z-index: 1;
display: grid;
place-items: center;
align-content: center;
gap: 14px;
padding: 24px;
background: var(--bg);
.banner { width: min(520px, 100%); }
}
.lean-spinner {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid color-mix(in srgb, var(--accent) 25%, transparent);
border-top-color: var(--accent);
animation: lean-spin 0.7s linear infinite;
}
@keyframes lean-spin {
to { transform: rotate(1turn); }
}
.lean-loading-text {
margin: 0;
font-size: 0.86rem;
color: var(--muted);
}
// small screens: the panel lies under the editor instead of beside it —
// and collapses into the bottom edge the same way
@media (max-width: 900px) {
.lean-work {
grid-template-columns: none;
grid-template-rows: minmax(0, 1fr) minmax(0, 40%);
transition: grid-template-rows 0.22s ease;
}
.lean-work[data-panel='collapsed'] {
grid-template-columns: none;
grid-template-rows: minmax(0, 1fr) minmax(0, 0%);
}
.lean-work-panel {
border-left: 0;
border-top: 1px solid var(--border);
}
}
@media (prefers-reduced-motion: reduce) {
.lean-stage { transition: none; }
.lean-work { transition: none; }
.lean-work-panel { transition: opacity 0.01s ease, visibility 0s; }
.lean-work[data-panel='collapsed'] .lean-work-panel { transition: opacity 0.01s ease, visibility 0s; }
.lean-spinner { animation-duration: 1.4s; }
.lean-work-status[data-busy='true'] .lean-work-dot { animation: none; }
}

View File

@@ -27,4 +27,5 @@
@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 'lean'; // the Lean editor: a frame over the lean4web server (lean/)
@use 'transitions'; // route fades, scrollbars

View File

@@ -127,6 +127,21 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
</svg>
<span class="hide-collapsed">{{ t('sidebar.calculator') }}</span>
</NuxtLink>
<NuxtLink to="/calculator3d" class="nav-link" :title="t('sidebar.calculator3dHint')">
<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="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.calculator3d') }}</span>
</NuxtLink>
<NuxtLink to="/lean" class="nav-link" :title="t('sidebar.leanHint')">
<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="M14.5 4 6.5 20" />
<path d="m10.5 12 7 8" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.lean') }}</span>
</NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/admin"

View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
import { expressionToLatex } from '~/utils/plot-expression'
import type { SurfaceFunction } from '~/utils/plot-expression'
import type { SurfaceSeries } from './Scene3D.vue'
/* The 3D calculator: the space owns the surface, the inputs dock to its
left edge — foldable, so the space can have every pixel. Each field
reads as z = f(x, y), and the surface standing over that ground is
drawn (Scene3D turns it under the hand). A name that is none of x, y,
the constants or the known functions — "k" — is a slider, as on the
plane: a lone "k" or a "k = 2" claims one, and every surface speaking
the name answers to its value. Everything is compiled as it is typed;
one that reads wrong goes red and simply stops being drawn. The
rows, the sliders' values and the dock's fold are the reader's own —
kept between visits and between instances, the way the plane calculator
keeps its own; the two calculators never share rows, only the shapes of
their docks.
The /calculator3d 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 surface 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-calculator3d-plots'
const VARS_KEY = 'mathew-calculator3d-vars'
const DOCK_KEY = 'mathew-calc3d-dock'
const MAX_ROWS = 12
let nextId = 1
const rows = ref<PlotRow[]>([
{ id: nextId++, expr: 'sin(sqrt(x^2 + y^2))', color: PALETTE[0]!, visible: true },
{ id: nextId++, expr: '(x^2 - y^2) / 4', color: PALETTE[1]!, visible: true },
])
/* one compile pass per render: the rows read alongside what they became —
a surface to draw, a slider to wear, and a reason for the ones that read
wrong (an empty field is nothing asked for, not a mistake). The "z ="
gutter stands only while the field reads as a surface — a slider or
unfinished text wears nothing, and the input takes back the room. One
that reads right carries its LaTeX too: the field echoes it typeset. */
interface PlotEntry {
row: PlotRow
kind: 'surface' | 'variable' | null
fn: SurfaceFunction | null
// the free variables this row speaks, and the slider its own text claims
vars: string[]
declared: { name: string; value: number | null } | null
error: 'unknown' | 'syntax' | null
tex: string | null
}
/* the sliders' values, keyed by variable. The compile pass reads them —
which is what ties every drawn surface to every slider: move one, every
expression speaking it recompiles and the space follows. */
const varValues = reactive<Record<string, number>>({})
const entries = computed<PlotEntry[]>(() => {
const scope = { ...varValues }
return rows.value.map((row) => {
const src = row.expr.trim()
if (!src) return { row, kind: null, fn: null, vars: [], declared: null, error: null, tex: null }
const result = compileSurfaceExpression(src, scope)
if (result.ok)
return {
row,
kind: result.kind,
fn: result.kind === 'surface' ? result.fn : null,
vars: result.vars,
declared: result.kind === 'variable' ? { name: result.name, value: result.value } : null,
error: null,
tex: expressionToLatex(src),
}
return { row, kind: null, fn: null, vars: [], declared: null, error: result.reason === 'empty' ? null : result.reason, tex: null }
})
})
/* the sliders: one per variable, hung under the row that claims it — the
declaration row when one asks, else the first row speaking it — values
kept between visits, as the rows are */
const { slidersByRow, setVar } = useVarSliders(
varValues,
() => entries.value.map((e) => ({ rowId: e.row.id, vars: e.vars, declared: e.declared })),
VARS_KEY,
)
/* the id of a row's field, so a declaration's inline slider can point back at
it (a label waking its input) to edit "k = 3" again — namespaced per
calculator so an instance of each open in the book never shares an id */
const INPUT_SCOPE = 'space'
const fieldId = (rowId: number) => `calc-input-${INPUT_SCOPE}-${rowId}`
/* a row whose own text declared a variable ("k", "k = 3") and whose slider
was anchored here wears that slider in place of its field once the field
rests — the field folds away (see .calc-input-box.var-decl) and the slider
takes the row, so a made variable reads as just a slider, not a "k = 3"
box above a slider */
function wearsInlineSlider(e: PlotEntry): boolean {
return e.kind === 'variable' && (slidersByRow.value.get(e.row.id)?.length ?? 0) > 0
}
const series = computed<SurfaceSeries[]>(() => {
const out: SurfaceSeries[] = []
for (const e of entries.value) {
if (!e.fn || !e.row.visible) continue
out.push({ id: e.row.id, color: e.row.color, visible: true, fn: e.fn })
}
return out
})
/* a new surface 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]!
}
/* Enter finishes a line the way clicking away does — it lets go of the field,
so the text settles (a surface folds to its typeset face, a variable to its
slider). On the last line the list invites one more, so the fresh field
takes the caret and the line you confirmed folds away; on any other line,
or once the list is full, Enter simply releases the field. */
async function onEnter(id: number, ev: KeyboardEvent) {
const el = ev.target as HTMLElement | null
if (id === rows.value[rows.value.length - 1]?.id) {
const before = rows.value.length
addRow()
if (rows.value.length !== before) {
const newId = rows.value[rows.value.length - 1]!.id
await nextTick()
document.getElementById(fieldId(newId))?.focus()
return
}
}
el?.blur()
}
/* whether the dock stands open is the reader's own — the fold slides the
rows away and leaves the space 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 surfaces stay */
}
})
const { t } = useI18n()
</script>
<template>
<div class="calc-stage">
<Scene3D :series="series" />
<Transition name="calc-dock">
<aside v-if="dockOpen" class="calc-dock" :aria-label="t('calc3d.functionsPanel')">
<div class="calc-dock-head">
<h2 class="calc-dock-title">{{ t('calc3d.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">
<!-- a slider declaration draws nothing, so it wears no hue and
has nothing to show or hide in the space -->
<button
v-if="e.kind !== 'variable'"
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="{ bare: e.kind !== 'surface', typeset: !!e.tex && e.kind !== 'variable', 'var-decl': wearsInlineSlider(e) }">
<!-- the z= gutter stands only while the field reads as a
surface; a slider or unfinished text wears nothing -->
<span v-if="e.kind === 'surface'" class="calc-y" aria-hidden="true">z=</span>
<input
:id="fieldId(e.row.id)"
v-model="e.row.expr"
class="calc-input"
:class="{ 'input-error': !!e.error }"
type="text"
:aria-label="t('calc3d.expressionAria')"
autocomplete="off"
spellcheck="false"
autocapitalize="off"
@keydown.enter="onEnter(e.row.id, $event)"
/>
<KatexSpan v-if="e.tex && e.kind !== 'variable'" class="calc-typeset" :tex="e.tex" />
</label>
<!-- a made variable shows the slider in place of its field; the
field stays mounted behind it (folded to nothing), and the
slider's name points back at it to wake the text -->
<VarSliders
v-if="wearsInlineSlider(e)"
class="calc-row-vars"
inline
:input-for="fieldId(e.row.id)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<button
v-if="e.kind !== 'variable'"
type="button"
class="panel-icon-btn"
:title="e.row.visible ? t('calc3d.hide') : t('calc3d.show')"
:aria-label="e.row.visible ? t('calc3d.hide') : t('calc3d.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>
<!-- the sliders this row wears, just beneath it (a made variable
wears its slider in the row itself, above, not here) -->
<VarSliders
v-if="!wearsInlineSlider(e)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<span v-if="e.error" class="calc-error">{{
t(e.error === 'unknown' ? 'calc.errorUnknown' : 'calc3d.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('calc3d.panelHeading') }}
</button>
</div>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { expressionToLatex } from '~/utils/plot-expression'
import type { PlaneFunction, PlotFunction } from '~/utils/plot-expression'
import type { PlotSeries } from './PlotCanvas.vue'
@@ -6,10 +7,14 @@ import type { PlotSeries } from './PlotCanvas.vue'
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.
so any equation in two variables plots. A name that is none of x, y, the
constants or the known functions — "k" — is a slider: a lone "k" or a
"k = 2" claims one, and every expression speaking the name answers to its
value, so one drag traces a whole family of curves at once. Everything is
compiled as it is typed; one that reads wrong goes red and simply stops
being drawn. The rows, the sliders' values 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. */
@@ -25,6 +30,7 @@ const PALETTE = [
'#e5484d', '#0091ff', '#46a758', '#f76808', '#8e4ec6', '#12a594', '#e93d82', '#3e63dd',
]
const STORAGE_KEY = 'mathew-calculator-plots'
const VARS_KEY = 'mathew-calculator-vars'
const DOCK_KEY = 'mathew-calc-dock'
const MAX_ROWS = 12
@@ -37,34 +43,87 @@ const seedRows = (): PlotRow[] => [
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. */
a function or relation to plot, a point to place, a slider to wear, and a
reason for the ones that read wrong (an empty field is nothing asked for,
not a mistake). The "y =" gutter stands only while the field reads as a
function — a relation, point, slider or unfinished text wears nothing,
and the input takes back the room. One that reads
right carries its LaTeX too: the field echoes it typeset. */
interface PlotEntry {
row: PlotRow
kind: 'function' | 'relation' | null
kind: 'function' | 'relation' | 'point' | 'variable' | null
fn: PlotFunction | PlaneFunction | null
// a point carries no fn — its dot lives at coordinates instead
point: { x: number; y: number; label: string | null } | null
// the free variables this row speaks, and the slider its own text claims
vars: string[]
declared: { name: string; value: number | null } | null
error: 'unknown' | 'syntax' | null
tex: string | null
}
const entries = computed<PlotEntry[]>(() =>
rows.value.map((row) => {
/* the sliders' values, keyed by variable. The compile pass reads them —
which is what ties every drawn curve to every slider: move one, every
expression speaking it recompiles and the plane follows. */
const varValues = reactive<Record<string, number>>({})
const entries = computed<PlotEntry[]>(() => {
const scope = { ...varValues }
return 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 }
}),
if (!src) return { row, kind: null, fn: null, point: null, vars: [], declared: null, error: null, tex: null }
const result = compilePlotExpression(src, scope)
if (result.ok)
return {
row,
kind: result.kind,
fn: 'fn' in result ? result.fn : null,
point: result.kind === 'point' ? { x: result.x, y: result.y, label: result.label } : null,
vars: result.vars,
declared: result.kind === 'variable' ? { name: result.name, value: result.value } : null,
error: null,
tex: expressionToLatex(src),
}
return { row, kind: null, fn: null, point: null, vars: [], declared: null, error: result.reason === 'empty' ? null : result.reason, tex: null }
})
})
/* the sliders: one per variable, hung under the row that claims it — the
declaration row when one asks, else the first row speaking it — values
kept between visits, as the rows are */
const { slidersByRow, setVar } = useVarSliders(
varValues,
() => entries.value.map((e) => ({ rowId: e.row.id, vars: e.vars, declared: e.declared })),
VARS_KEY,
)
/* the id of a row's field, so a declaration's inline slider can point back at
it (a label waking its input) to edit "k = 3" again — namespaced per
calculator so an instance of each open in the book never shares an id */
const INPUT_SCOPE = 'plane'
const fieldId = (rowId: number) => `calc-input-${INPUT_SCOPE}-${rowId}`
/* a row whose own text declared a variable ("k", "k = 3") and whose slider
was anchored here wears that slider in place of its field once the field
rests — the field folds away (see .calc-input-box.var-decl) and the slider
takes the row, so a made variable reads as just a slider, not a "k = 3"
box above a slider */
function wearsInlineSlider(e: PlotEntry): boolean {
return e.kind === 'variable' && (slidersByRow.value.get(e.row.id)?.length ?? 0) > 0
}
const series = computed<PlotSeries[]>(() => {
const out: PlotSeries[] = []
for (const e of entries.value) {
if (!e.kind || !e.fn || !e.row.visible) continue
if (!e.kind || e.kind === 'variable' || !e.row.visible) continue
// the union is keyed to the compile result's kind, which picks fn's shape
if (e.kind === 'function')
if (e.kind === 'point' && e.point) {
out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'point', ...e.point })
} else if (e.kind === 'function' && e.fn) {
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 })
} else if (e.kind === 'relation' && e.fn) {
out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'relation', fn: e.fn as PlaneFunction })
}
}
return out
})
@@ -96,10 +155,24 @@ function cycleColor(id: number) {
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()
/* Enter finishes a line the way clicking away does — it lets go of the field,
so the text settles (a function folds to its typeset face, a variable to its
slider). On the last line the list invites one more, so the fresh field
takes the caret and the line you confirmed folds away; on any other line,
or once the list is full, Enter simply releases the field. */
async function onEnter(id: number, ev: KeyboardEvent) {
const el = ev.target as HTMLElement | null
if (id === rows.value[rows.value.length - 1]?.id) {
const before = rows.value.length
addRow()
if (rows.value.length !== before) {
const newId = rows.value[rows.value.length - 1]!.id
await nextTick()
document.getElementById(fieldId(newId))?.focus()
return
}
}
el?.blur()
}
/* whether the dock stands open is the reader's own — the fold slides the
@@ -183,7 +256,10 @@ const { t } = useI18n()
<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">
<!-- a slider declaration draws nothing, so it wears no hue and
has nothing to show or hide on the plane -->
<button
v-if="e.kind !== 'variable'"
type="button"
class="calc-swatch"
:style="{ background: e.row.color }"
@@ -191,9 +267,16 @@ const { t } = useI18n()
: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>
<label
class="calc-input-box"
:class="{ bare: e.kind !== 'function', typeset: !!e.tex && e.kind !== 'variable', 'var-decl': wearsInlineSlider(e) }"
>
<!-- the y= gutter stands only while the field reads as a
function; a relation, point, slider or unfinished
text carries its own sense and wears nothing -->
<span v-if="e.kind === 'function'" class="calc-y" aria-hidden="true">y=</span>
<input
:id="fieldId(e.row.id)"
v-model="e.row.expr"
class="calc-input"
:class="{ 'input-error': !!e.error }"
@@ -202,10 +285,23 @@ const { t } = useI18n()
autocomplete="off"
spellcheck="false"
autocapitalize="off"
@keydown.enter="onEnter(e.row.id)"
@keydown.enter="onEnter(e.row.id, $event)"
/>
<KatexSpan v-if="e.tex && e.kind !== 'variable'" class="calc-typeset" :tex="e.tex" />
</label>
<!-- a made variable shows the slider in place of its field; the
field stays mounted behind it (folded to nothing), and the
slider's name points back at it to wake the text -->
<VarSliders
v-if="wearsInlineSlider(e)"
class="calc-row-vars"
inline
:input-for="fieldId(e.row.id)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<button
v-if="e.kind !== 'variable'"
type="button"
class="panel-icon-btn"
:title="e.row.visible ? t('calc.hide') : t('calc.show')"
@@ -236,6 +332,13 @@ const { t } = useI18n()
</svg>
</button>
</div>
<!-- the sliders this row wears, just beneath it (a made variable
wears its slider in the row itself, above, not here) -->
<VarSliders
v-if="!wearsInlineSlider(e)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<span v-if="e.error" class="calc-error">{{
t(e.error === 'unknown' ? 'calc.errorUnknown' : 'calc.errorSyntax')
}}</span>

View File

@@ -0,0 +1,424 @@
<script setup lang="ts">
/* The Lean 4 editor, native to the wiki.
*
* Where the iframe framed another server's whole app, this page *is* the
* editor: Monaco wears the wiki's own theme, and the wiki talks Lean
* directly — one websocket to the lean4web server (see lean/), carrying
* the language protocol: the buffer goes across as it is typed, errors and
* `#check` output come back as diagnostics, and the goal at the cursor
* answers `$/lean/plainGoal`. The infoview is therefore the wiki's own —
* Goals and Messages panes below the wiki's own hand — and it follows the
* wiki's theme live, which a cross-origin frame never could.
*
* Lean's unicode input survives too (`\le` → `≤`), and the classic lean4web
* client stays one click away for its own richer toys (collaboration,
* widgets). Monaco is loaded dynamically: this component only ever runs in
* the browser, and the editor is far too heavy to ride along in the
* server bundle. */
import type * as Monaco from 'monaco-editor'
import { leanAbbreviations } from '~/utils/lean/abbreviations'
const { t } = useI18n()
const { th } = useAppLocale()
const session = useLeanSession()
const leanUrl = String(useRuntimeConfig().public.leanUrl || '')
const editorEl = ref<HTMLElement>()
let monaco: typeof Monaco | null = null
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
let disposables: Array<{ dispose: () => void }> = []
let themeObserver: MutationObserver | null = null
/* whether the infoview stands open is the reader's own — the fold slides it
* away and leaves the editor the whole width; the choice is kept between
* visits, the way the calculators keep their docks */
const PANEL_KEY = 'mathew:lean-panel-open'
const panelOpen = ref(true)
function togglePanel() {
panelOpen.value = !panelOpen.value
try {
localStorage.setItem(PANEL_KEY, panelOpen.value ? '1' : '0')
}
catch {
/* private mode etc. — the fold just won't persist */
}
}
const phaseText = computed(() => {
switch (session.phase.value) {
case 'connecting': return t('lean.connecting')
case 'initializing': return t('lean.initializing')
case 'ready': return session.busy.value ? t('lean.elaborating') : t('lean.ready', { version: session.version.value ?? '' })
default: return ''
}
})
onMounted(async () => {
if (!editorEl.value) return
try {
panelOpen.value = localStorage.getItem(PANEL_KEY) !== '0'
}
catch {
/* storage unreadable — the panel stays open */
}
const monacoMod = await import('monaco-editor')
const { registerLeanLanguage, currentLeanTheme, applyLeanTheme, LEAN_LANGUAGE } = await import('~/utils/lean/lean-language')
monaco = monacoMod
registerLeanLanguage()
/* Monaco runs from one web worker (text processing). The `?worker` import
* lives in a wrapper (`utils/lean/monaco-worker`) that this pulls in only
* here, in the browser. */
if (!window.MonacoEnvironment) {
const { EditorWorker } = await import('~/utils/lean/monaco-worker')
window.MonacoEnvironment = { getWorker: () => new EditorWorker() }
}
if (!session.text.value) session.text.value = t('lean.seedDoc')
editor = monacoMod.editor.create(editorEl.value, {
value: session.text.value,
language: LEAN_LANGUAGE,
theme: currentLeanTheme(),
fontFamily: "ui-monospace, 'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",
fontSize: 13.5,
lineHeight: 23,
tabSize: 2,
minimap: { enabled: false },
lineNumbersMinChars: 2,
scrollBeyondLastLine: false,
smoothScrolling: true,
automaticLayout: true,
padding: { top: 16 },
fixedOverflowWidgets: true,
quickSuggestions: false,
// Lean files are full of legitimate unicode — no confusable warnings
unicodeHighlight: { ambiguousCharacters: false, invisibleCharacters: false },
ariaLabel: t('lean.editorAria'),
})
/* edits → the server (debounced inside the session); cursor → goals */
disposables.push(
editor.onDidChangeModelContent(() => session.change(editor!.getValue())),
editor.onDidChangeCursorPosition(({ position }) => {
session.cursorAt(position.lineNumber - 1, position.column - 1)
}),
)
/* diagnostics → editor marks, in the wiki's reds and ambers (the theme
* owns the colours); the session replays the current set on (re)attach.
* The session hands back a plain unsubscribe function, not an IDisposable
* like Monaco's handles — wrap it, or unmounting would throw over the
* whole page */
const offDiagnostics = session.onDiagnostics((diags) => {
const model = editor?.getModel()
if (!model || !monaco) return
monaco.editor.setModelMarkers(model, 'lean', diags.map(d => ({
severity: d.severity === 1
? monaco!.MarkerSeverity.Error
: d.severity === 2
? monaco!.MarkerSeverity.Warning
: d.severity === 4
? monaco!.MarkerSeverity.Hint
: monaco!.MarkerSeverity.Info,
message: d.message,
startLineNumber: d.range.start.line + 1,
startColumn: d.range.start.character + 1,
endLineNumber: d.range.end.line + 1,
endColumn: d.range.end.character + 1,
})))
})
disposables.push({ dispose: () => offDiagnostics() })
registerProviders(monacoMod, editor)
/* the wiki's theme *and* accent switches reach the editor live — same page
* now; applyLeanTheme re-reads the --accent tokens, so the caret, selection
* and keywords follow whichever accent the account wears */
themeObserver = new MutationObserver(() => {
applyLeanTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-accent'],
})
await session.start()
const position = editor.getPosition()
if (position) session.cursorAt(position.lineNumber - 1, position.column - 1)
})
onBeforeUnmount(() => {
// the session outlives the component on purpose (see useLeanSession)
themeObserver?.disconnect()
for (const d of disposables) d.dispose()
disposables = []
editor?.dispose()
editor = null
})
/* ---------- LSP-backed intelligence ---------- */
function registerProviders(monacoMod: typeof Monaco, host: Monaco.editor.IStandaloneCodeEditor) {
const toRange = (r: { start: { line: number, character: number }, end: { line: number, character: number } }) => ({
startLineNumber: r.start.line + 1,
startColumn: r.start.character + 1,
endLineNumber: r.end.line + 1,
endColumn: r.end.character + 1,
})
/* the cursor, in server (0-based) coordinates. Lean's LSP and Monaco both
* count positions in UTF-16 code units, so a column simply shifts by one */
const positionOf = (_model: Monaco.editor.ITextModel, position: Monaco.Position) => ({
line: position.lineNumber - 1,
character: position.column - 1,
})
/* Lean tactic/term completions */
disposables.push(monacoMod.languages.registerCompletionItemProvider('lean4', {
triggerCharacters: [],
provideCompletionItems: async (model, position) => {
const word = model.getWordUntilPosition(position)
const fallbackRange = {
startLineNumber: position.lineNumber,
startColumn: word.wordMinimumColumn,
endLineNumber: position.lineNumber,
endColumn: position.column,
}
try {
const res = await session.request<{ items?: unknown[] } | unknown[]>(
'textDocument/completion',
{
textDocument: { uri: session.serverDocUri.value },
position: positionOf(model, position),
context: { triggerKind: 1 },
},
)
const items = Array.isArray(res) ? res : (res.items ?? [])
return {
incomplete: true,
suggestions: (items as any[]).slice(0, 300).map((item) => {
const insertText = String(item.insertText ?? item.label ?? '')
const range = item.textEdit?.range ?? item.range
const doc = item.documentation
const suggestion = {
label: String(item.label ?? insertText),
kind: lspCompletionKind(monacoMod, item.kind),
insertText,
insertTextRules: item.insertTextFormat === 2
? monacoMod.languages.CompletionItemInsertTextRule.InsertAsSnippet
: undefined,
detail: item.detail ? String(item.detail) : undefined,
documentation: doc
? (typeof doc === 'string'
? { value: doc }
: { value: String(doc.value ?? ''), isMarkdown: doc.kind === 'markdown' })
: undefined,
range: range ? toRange(range) : fallbackRange,
// Lean attachments (auto-imports) ride along once confirmed
additionalTextEdits: undefined,
command: item.additionalTextEdits?.length
? {
id: 'mathew-lean-apply-edits',
title: '',
arguments: [item.additionalTextEdits],
}
: undefined,
}
return suggestion
}),
}
}
catch { return { suggestions: [] } }
},
}))
/* Lean unicode input: `\le` → `≤`, the wiki's own copy of the table */
disposables.push(monacoMod.languages.registerCompletionItemProvider('lean4', {
triggerCharacters: ['\\'],
provideCompletionItems: (model, position) => {
const line = model.getLineContent(position.lineNumber)
const upto = line.slice(0, position.column - 1)
const match = /\\([^\s]*)$/.exec(upto)
if (!match) return { suggestions: [] }
const typed = match[1]
const range = {
startLineNumber: position.lineNumber,
startColumn: position.column - typed.length,
endLineNumber: position.lineNumber,
endColumn: position.column,
}
const keys = Object.keys(leanAbbreviations)
.filter(k => k.startsWith(typed))
.sort((a, b) => a.length - b.length || a.localeCompare(b))
.slice(0, 100)
return {
suggestions: keys.map(k => ({
label: `\\${k}`,
kind: monacoMod.languages.CompletionItemKind.SymbolValue,
insertText: snippetFrom(leanAbbreviations[k]),
insertTextRules: monacoMod.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: leanAbbreviations[k].replace(/\$CURSOR/g, ''),
range,
})),
}
},
}))
/* hover: Lean's docstrings, as markdown, in the wiki's widget colours */
disposables.push(monacoMod.languages.registerHoverProvider('lean4', {
provideHover: async (model, position) => {
try {
const res = await session.request<{
contents?: { kind: string, value: string } | string
} | null>('textDocument/hover', {
textDocument: { uri: session.serverDocUri.value },
position: positionOf(model, position),
})
if (!res?.contents) return null
const value = typeof res.contents === 'string' ? res.contents : res.contents.value
return { contents: [{ value }] }
}
catch { return null }
},
}))
/* auto-imports the server attached to a chosen completion: an action
* registers under its own id, which is what the completion's `command`
* invokes after inserting the text */
disposables.push(host.addAction({
id: 'mathew-lean-apply-edits',
label: 'Apply Lean imports',
run: (target, ...args) => {
const edits = args[0] as Array<{
range: { start: { line: number, character: number }, end: { line: number, character: number } }
newText: string
}> | undefined
if (!edits) return
target.executeEdits('lean-import', edits.map(e => ({
range: monacoMod.Range.lift(toRange(e.range)),
text: e.newText,
})))
},
}))
}
/* LSP completion kinds (1-based) → Monaco's; anything unmapped is a value */
function lspCompletionKind(monacoMod: typeof Monaco, kind?: number) {
const K = monacoMod.languages.CompletionItemKind
const map: Record<number, Monaco.languages.CompletionItemKind> = {
1: K.Text, 2: K.Method, 3: K.Function, 4: K.Constructor, 5: K.Field,
6: K.Variable, 7: K.Class, 8: K.Interface, 9: K.Module, 10: K.Property,
11: K.Unit, 12: K.Value, 13: K.Enum, 14: K.Keyword, 15: K.Snippet,
16: K.Color, 17: K.File, 18: K.Reference, 19: K.Folder, 20: K.EnumMember,
21: K.Constant, 22: K.Struct, 23: K.Event, 24: K.Operator, 25: K.TypeParameter,
}
return (kind !== undefined && map[kind]) || K.Value
}
/* the table's `$CURSOR` becomes a Monaco snippet tab stop; other `$` are literal */
function snippetFrom(value: string) {
return value
.split('$CURSOR')
.map(part => part.replace(/\\/g, '\\\\').replace(/\$/g, '\\$'))
.join('$0')
}
</script>
<template>
<div class="lean-stage lean-work-shell">
<div class="lean-work" :data-panel="panelOpen ? 'open' : 'collapsed'">
<div ref="editorEl" class="lean-work-editor" :aria-label="t('lean.editorAria')" />
<aside class="lean-work-panel">
<div class="lean-work-status" :data-phase="session.phase.value" :data-busy="session.busy.value ? 'true' : null">
<span class="lean-work-dot" aria-hidden="true" />
<span class="lean-work-phase">{{ phaseText }}</span>
<a
class="lean-work-classic"
:href="leanUrl"
target="_blank"
rel="noopener"
:title="t('lean.openExternal')"
:aria-label="t('lean.openExternal')"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
</a>
<button
type="button"
class="lean-work-collapse"
:title="t('lean.collapse')"
:aria-label="t('lean.collapse')"
@click="togglePanel()"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
<section class="lean-work-goals">
<h3>{{ t('lean.goals') }}</h3>
<template v-if="session.goals.value.length">
<pre v-for="(goal, i) in session.goals.value" :key="i">{{ goal }}</pre>
</template>
<p v-else class="lean-work-none">{{ t('lean.noGoals') }}</p>
</section>
<section class="lean-work-messages">
<h3>{{ t('lean.messages') }}</h3>
<ul v-if="session.messages.value.length">
<li v-for="(m, i) in session.messages.value" :key="i">
<span class="lean-work-msg-line">{{ m.line }}</span>
<pre>{{ m.text }}</pre>
</li>
</ul>
<p v-else class="lean-work-none">—</p>
</section>
</aside>
<!-- while the panel is folded away, a quiet card at the editor's
top-right corner waits to bring it back -->
<button
v-if="!panelOpen"
type="button"
class="lean-panel-open"
:title="t('lean.expand')"
:aria-label="t('lean.expand')"
@click="togglePanel()"
>
<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>
{{ t('lean.infoview') }}
</button>
</div>
<!-- the editor answers as soon as Lean answers; until then the wiki
speaks for it -->
<div v-if="!leanUrl" class="lean-state">
<div class="banner">
<span class="glyph">!</span>
<span v-html="t('lean.noServer')"></span>
</div>
</div>
<div v-else-if="session.phase.value === 'lost'" class="lean-state">
<div class="banner">
<span class="glyph">!</span>
<span v-html="th('lean.unreachable', { url: leanUrl })"></span>
</div>
<button type="button" class="btn btn-ghost btn-sm lean-retry" @click="session.retry()">
{{ t('lean.retry') }}
</button>
</div>
<div v-else-if="session.phase.value === 'connecting' || session.phase.value === 'initializing'" class="lean-state">
<span class="lean-spinner" aria-hidden="true" />
<p class="lean-loading-text">{{ phaseText }}</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,832 @@
<script setup lang="ts">
/* The calculator's space: one canvas painted with a labelled box — a floor
grid, the three axes with their numbers — and any number of surfaces,
each z = f(x, y) sampled into a mesh over the plane and laid down quad by
quad, farthest first, every one shaded by how its face lies against the
light. No 3D library: the camera orbits the box's centre and the
projection is the schoolbook one — a point lands where the line to the
eye crosses the picture plane. A quad whose corners are not all finite,
or not all under the drawn height, is simply left out, so 1/(x·y) keeps
its holes rather than a false sheet; the height the box spans is the
data's own, its extremes trimmed so one pole cannot flatten the rest.
Drag turns the scene, the wheel moves closer or away, and a double-click
returns to the default view. Grid, axes and labels follow the site
theme, accent included. */
import type { SurfaceFunction } from '~/utils/plot-expression'
export interface SurfaceSeries {
id: number
color: string
visible: boolean
fn: SurfaceFunction
}
const props = defineProps<{ series: SurfaceSeries[] }>()
const wrapEl = ref<HTMLElement | null>(null)
const canvasEl = ref<HTMLCanvasElement | null>(null)
const cursor = ref<'grab' | 'grabbing'>('grab')
const { t } = useI18n()
/* the space the surfaces stand on, as the reader sets it in the card:
where each edge runs to, and how finely the mesh cuts each side —
clamped short, since a frame holds (n − 1)² quads per surface. z by
default follows the surfaces' own height; the card's switch hands the
two bounds to the reader instead. One person's space, kept in
localStorage between visits, the way the rows and the dock are */
const SPACE_KEY = 'mathew-calc3d-space'
const SUBDIV_MIN = 2
const SUBDIV_MAX = 500
const space = reactive({
xMin: -6,
xMax: 6,
yMin: -6,
yMax: 6,
zAuto: true,
zMin: -1,
zMax: 1,
subdiv: 40,
})
/* the height the box drew last — the auto range the card reads out, and
the prefill the manual bounds take when auto switches off, so the
scene never jumps on the toggle */
const drawnZlo = ref(-1)
const drawnZhi = ref(1)
/* the camera: azimuth and elevation about the scene's centre, and how far
in it stands. Plain mutable state — it moves with every drag and wheel,
and the double-click sets it back. */
const cam = { yaw: 0.96, pitch: 0.42, zoom: 1 }
const YAW0 = 0.96
const PITCH0 = 0.42
const MIN_PITCH = -1.42
const MAX_PITCH = 1.42
const MIN_ZOOM = 0.15
const MAX_ZOOM = 30
/* how strong the perspective is: the eye stands this many scene radii
back from the picture plane */
const EYE = 5.5
let width = 0
let height = 0
let dpr = 1
/* the scene is turned and fitted into a unit ball: its centre and radius
follow the space as set — the edges' middle, the height range drawn —
so whatever it holds fills the picture. All are recomputed at the top
of every paint. */
let cx = 0
let cy = 0
let cz = 0
let radius = 1
/* world → camera: yaw about z, then tilt about the screen's horizontal;
x is screen-right, y screen-up, z depth away from the eye */
function toView(x: number, y: number, z: number) {
const px = (x - cx) / radius
const py = (y - cy) / radius
const pz = (z - cz) / radius
const sa = Math.sin(cam.yaw)
const ca = Math.cos(cam.yaw)
const se = Math.sin(cam.pitch)
const ce = Math.cos(cam.pitch)
return {
x: px * ca + py * sa,
y: -px * sa * se + py * ca * se + pz * ce,
z: -px * sa * ce + py * ca * ce - pz * se,
}
}
/* camera → canvas: the line to the eye, read at the picture plane. At the
scene's middle (z = 0) one scene unit is 42% of the shorter edge. */
function toScreen(v: { x: number; y: number; z: number }) {
const s = ((Math.min(width, height) / 2) * 0.84 * EYE * cam.zoom) / (EYE + v.z)
return { x: width / 2 + v.x * s, y: height / 2 - v.y * s }
}
/* 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 light hangs above and to the left of the eye; faces are shaded by
how squarely they turn to it (both sides of a face read, so the mesh's
underside is no darker than its top) */
const LIGHT = { x: -0.3, y: 0.55, z: -0.78 }
const SHADE_CACHE = new Map<string, string>()
function shade(hex: string, light: number): string {
const level = Math.round(light * 48)
const key = `${hex}|${level}`
const cached = SHADE_CACHE.get(key)
if (cached) return cached
const rgb = /^#[0-9a-f]{6}$/i.test(hex) ? Number.parseInt(hex.slice(1), 16) : 0x8a93a8
const at = (shift: number) =>
Math.max(0, Math.min(255, Math.round((((rgb >> shift) & 255) * level) / 48)))
const css = `rgb(${at(16)},${at(8)},${at(0)})`
SHADE_CACHE.set(key, css)
return css
}
/* about six labelled lines along an axis, at a 1/2/5 × 10ⁿ step */
function niceStep(span: number) {
const rough = span / 6
const pow = Math.pow(10, Math.floor(Math.log10(rough)))
const m = rough / pow
const mult = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10
return mult * pow
}
function formatTick(v: number, step: number): string {
if (Math.abs(v) < step / 2) 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)
}
/* the space as it may be drawn: honest edges (an unfinished or reversed
entry falls back to the default pair) and a clamped mesh fineness.
Read at the top of every paint; the height range fills in once the
surfaces have been sampled */
interface Scene {
xLo: number
xHi: number
yLo: number
yHi: number
zlo: number
zhi: number
n: number // mesh points along one edge; the quads are (n − 1)²
}
function pair(lo: unknown, hi: unknown, dLo: number, dHi: number): [number, number] {
return typeof lo === 'number' && typeof hi === 'number' && Number.isFinite(lo) && Number.isFinite(hi) && lo < hi
? [lo, hi]
: [dLo, dHi]
}
function readScene(): Scene {
const [xLo, xHi] = pair(space.xMin, space.xMax, -6, 6)
const [yLo, yHi] = pair(space.yMin, space.yMax, -6, 6)
const raw = Number(space.subdiv)
const subdiv = Number.isFinite(raw)
? Math.min(SUBDIV_MAX, Math.max(SUBDIV_MIN, Math.round(raw)))
: 40
return { xLo, xHi, yLo, yHi, zlo: -1, zhi: 1, n: subdiv + 1 }
}
/* where the i-th mesh point falls along an edge, and where the k-th
multiple of a step falls between two edges, zeros and all */
const at = (lo: number, hi: number, i: number, n: number) => lo + ((hi - lo) * i) / (n - 1)
function marks(step: number, lo: number, hi: number): number[] {
const out: number[] = []
for (let k = Math.ceil(lo / step - 1e-9); k * step <= hi + 1e-9; k++) out.push(k * step)
return out
}
/* the floor line each horizontal axis runs along: 0 when the ground
holds it, else the edge of the ground that does */
const axisLine = (lo: number, hi: number) => Math.min(Math.max(lo, 0), hi)
/* one surface sampled on the mesh; anything the numbers refuse (NaN, ±∞)
stays a hole the mesh will not paper over */
function sample(sc: Scene, s: SurfaceFunction): Float64Array {
const n = sc.n
const z = new Float64Array(n * n)
for (let j = 0; j < n; j++) {
const y = at(sc.yLo, sc.yHi, j, n)
for (let i = 0; i < n; i++) {
const v = s(at(sc.xLo, sc.xHi, i, n), y)
z[j * n + i] = Number.isFinite(v) ? v : NaN
}
}
return z
}
/* one quantile of a sorted row of heights — the range trims to the 1st and
99th so a lone pole cannot stretch the box flat */
function quantile(sorted: number[], p: number): number {
const at = (sorted.length - 1) * p
const lo = Math.floor(at)
const hi = Math.ceil(at)
return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (at - lo)
}
interface Quad {
pts: { x: number; y: number }[]
depth: number
fill: string
}
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 sc = readScene()
/* one pass over the live surfaces: sample them whole, gather every
finite height, and let those decide the box's height — unless the
reader has taken the height in hand and switched the auto off */
const meshes: { color: string; z: Float64Array }[] = []
const seen: number[] = []
for (const s of props.series) {
if (!s.visible) continue
const z = sample(sc, s.fn)
meshes.push({ color: s.color, z })
for (let k = 0; k < z.length; k++) if (Number.isFinite(z[k]!)) seen.push(z[k]!)
}
const manual =
!space.zAuto && Number.isFinite(space.zMin) && Number.isFinite(space.zMax) && space.zMin < space.zMax
let zlo: number
let zhi: number
if (manual) {
zlo = space.zMin
zhi = space.zMax
} else if (seen.length) {
seen.sort((a, b) => a - b)
const lo = quantile(seen, 0.01)
const hi = quantile(seen, 0.99)
if (hi - lo < 1e-12) {
zlo = lo - 1
zhi = lo + 1
} else {
const pad = (hi - lo) * 0.06
zlo = lo - pad
zhi = hi + pad
}
} else {
zlo = -1
zhi = 1
}
sc.zlo = zlo
sc.zhi = zhi
drawnZlo.value = zlo
drawnZhi.value = zhi
cx = (sc.xLo + sc.xHi) / 2
cy = (sc.yLo + sc.yHi) / 2
cz = (zlo + zhi) / 2
radius = Math.hypot((sc.xHi - sc.xLo) / 2, (sc.yHi - sc.yLo) / 2, (zhi - zlo) / 2)
/* the vertical edge nearest the eye is the one that carries the z axis
and its numbers, so they never hide behind the scene's own centre;
of the four, the closest is the one with the least depth in toView */
const sa = Math.sin(cam.yaw)
const ca = Math.cos(cam.yaw)
let zx = sc.xHi
let zy = sc.yHi
let nearest = Infinity
for (const [x, y] of [
[sc.xHi, sc.yHi],
[sc.xHi, sc.yLo],
[sc.xLo, sc.yHi],
[sc.xLo, sc.yLo],
] as [number, number][]) {
const depth = -x * sa + y * ca // the px/py term of toView's z, before the tilt
if (depth < nearest) {
nearest = depth
zx = x
zy = y
}
}
drawBox(ctx, sc, zx, zy)
drawSurfaces(ctx, meshes, sc)
drawLabels(ctx, sc, zx, zy)
}
/* the box the scene lives in: twelve edges and a floor grid the ground
reads against */
function drawBox(ctx: CanvasRenderingContext2D, sc: Scene, zx: number, zy: number) {
const p = (x: number, y: number, z: number) => toScreen(toView(x, y, z))
const { xLo, xHi, yLo, yHi, zlo, zhi } = sc
ctx.strokeStyle = colors.gridMinor
ctx.lineWidth = 1
ctx.beginPath()
for (const m of marks(niceStep(xHi - xLo), xLo, xHi)) {
const a = p(m, yLo, zlo)
const b = p(m, yHi, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
for (const m of marks(niceStep(yHi - yLo), yLo, yHi)) {
const a = p(xLo, m, zlo)
const b = p(xHi, m, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
ctx.stroke()
ctx.strokeStyle = colors.gridMajor
ctx.beginPath()
const edges: [number, number, number, number, number, number][] = [
[xLo, yLo, zlo, xHi, yLo, zlo], [xHi, yLo, zlo, xHi, yHi, zlo],
[xHi, yHi, zlo, xLo, yHi, zlo], [xLo, yHi, zlo, xLo, yLo, zlo],
[xLo, yLo, zhi, xHi, yLo, zhi], [xHi, yLo, zhi, xHi, yHi, zhi],
[xHi, yHi, zhi, xLo, yHi, zhi], [xLo, yHi, zhi, xLo, yLo, zhi],
[xLo, yLo, zlo, xLo, yLo, zhi], [xHi, yLo, zlo, xHi, yLo, zhi],
[xHi, yHi, zlo, xHi, yHi, zhi], [xLo, yHi, zlo, xLo, yHi, zhi],
]
for (const e of edges) {
const a = p(e[0], e[1], e[2])
const b = p(e[3], e[4], e[5])
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
ctx.stroke()
// the x and y axes along the floor — along 0 while the ground holds it,
// else along the edge that does — and the z axis up the near edge
const y0 = axisLine(yLo, yHi)
const x0 = axisLine(xLo, xHi)
ctx.strokeStyle = colors.axis
ctx.lineWidth = 1.4
ctx.beginPath()
let a = p(xLo, y0, zlo)
let b = p(xHi, y0, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
a = p(x0, yLo, zlo)
b = p(x0, yHi, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
a = p(zx, zy, zlo)
b = p(zx, zy, zhi)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.stroke()
}
/* the surfaces, painted farthest-first: every quad of every mesh keeps its
view-space depth, all are sorted once together, so two surfaces crossing
each other still lay down the nearer one last */
function drawSurfaces(
ctx: CanvasRenderingContext2D,
meshes: { color: string; z: Float64Array }[],
sc: Scene,
) {
const n = sc.n
const { zlo, zhi } = sc
const quads: Quad[] = []
for (const m of meshes) {
for (let j = 0; j < n - 1; j++) {
const y0 = at(sc.yLo, sc.yHi, j, n)
const y1 = at(sc.yLo, sc.yHi, j + 1, n)
for (let i = 0; i < n - 1; i++) {
const x0 = at(sc.xLo, sc.xHi, i, n)
const x1 = at(sc.xLo, sc.xHi, i + 1, n)
const h00 = m.z[j * n + i]!
const h10 = m.z[j * n + i + 1]!
const h01 = m.z[(j + 1) * n + i]!
const h11 = m.z[(j + 1) * n + i + 1]!
if (!Number.isFinite(h00) || !Number.isFinite(h10) || !Number.isFinite(h01) || !Number.isFinite(h11))
continue
// a corner over the drawn height belongs to a pole's neighbourhood:
// the quad is left out, and the mesh keeps the hole honest
if (
h00 < zlo || h00 > zhi || h10 < zlo || h10 > zhi ||
h01 < zlo || h01 > zhi || h11 < zlo || h11 > zhi
)
continue
const v00 = toView(x0, y0, h00)
const v10 = toView(x1, y0, h10)
const v01 = toView(x0, y1, h01)
const v11 = toView(x1, y1, h11)
// the face's normal from its diagonals, in the camera's own frame
const ux = v11.x - v00.x
const uy = v11.y - v00.y
const uz = v11.z - v00.z
const wx = v10.x - v01.x
const wy = v10.y - v01.y
const wz = v10.z - v01.z
const nx = uy * wz - uz * wy
const ny = uz * wx - ux * wz
const nz = ux * wy - uy * wx
const len = Math.hypot(nx, ny, nz)
const light = len > 1e-12 ? 0.44 + 0.74 * Math.abs((nx * LIGHT.x + ny * LIGHT.y + nz * LIGHT.z) / len) : 0.74
quads.push({
pts: [toScreen(v00), toScreen(v10), toScreen(v11), toScreen(v01)],
depth: (v00.z + v10.z + v01.z + v11.z) / 4,
fill: shade(m.color, light),
})
}
}
}
quads.sort((a, b) => b.depth - a.depth) // farthest first, painter's order
ctx.lineJoin = 'round'
ctx.lineWidth = 1
for (const q of quads) {
ctx.beginPath()
ctx.moveTo(q.pts[0]!.x, q.pts[0]!.y)
ctx.lineTo(q.pts[1]!.x, q.pts[1]!.y)
ctx.lineTo(q.pts[2]!.x, q.pts[2]!.y)
ctx.lineTo(q.pts[3]!.x, q.pts[3]!.y)
ctx.closePath()
// filled and stroked in the same tone, so the antialiasing seams between
// neighbouring quads close and the surface reads as one sheet
ctx.fillStyle = q.fill
ctx.strokeStyle = q.fill
ctx.fill()
ctx.stroke()
}
}
/* axes' names and numbers, painted last so they read over any surface */
function drawLabels(ctx: CanvasRenderingContext2D, sc: Scene, zx: number, zy: number) {
const p = (x: number, y: number, z: number) => toScreen(toView(x, y, z))
const { xLo, xHi, yLo, yHi, zlo, zhi } = sc
const y0 = axisLine(yLo, yHi)
const x0 = axisLine(xLo, xHi)
ctx.fillStyle = colors.tick
ctx.font = "11px 'Computer Modern', Georgia, serif"
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
const xstep = niceStep(xHi - xLo)
for (const m of marks(xstep, xLo, xHi)) {
if (Math.abs(m) < 1e-9) continue
const a = p(m, y0, zlo)
ctx.fillText(formatTick(m, xstep), a.x, a.y + 5)
}
const ystep = niceStep(yHi - yLo)
for (const m of marks(ystep, yLo, yHi)) {
if (Math.abs(m) < 1e-9) continue
const b = p(x0, m, zlo)
ctx.fillText(formatTick(m, ystep), b.x, b.y + 5)
}
const zstep = niceStep(zhi - zlo)
ctx.textAlign = zx >= cx ? 'left' : 'right'
ctx.textBaseline = 'middle'
const off = zx >= cx ? 8 : -8
for (const m of marks(zstep, (zhi - zlo) / 2)) {
const v = cz + m
if (v < zlo || v > zhi) continue
const a = p(zx, zy, v)
ctx.fillText(formatTick(v, zstep), a.x + off, a.y)
}
ctx.font = "italic 13px 'Computer Modern', Georgia, serif"
ctx.fillStyle = colors.axis
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const xl = p(xHi, y0, zlo)
ctx.fillText('x', xl.x + 10, xl.y + 4)
const yl = p(x0, yHi, zlo)
ctx.fillText('y', yl.x - 10, yl.y + 4)
const zl = p(zx, zy, zhi)
ctx.fillText('z', zl.x + off, zl.y - 8)
}
/* 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 resetView() {
cam.yaw = YAW0
cam.pitch = PITCH0
cam.zoom = 1
requestDraw()
}
function zoomBy(factor: number) {
cam.zoom = Math.min(Math.max(cam.zoom * factor, MIN_ZOOM), MAX_ZOOM)
requestDraw()
}
/* the configuration card that folds out under the toolbar: the space the
surfaces stand in — the ground's two edges, the height last drawn, and
how finely the mesh cuts each side */
const menuOpen = ref(false)
const toolsEl = ref<HTMLElement | null>(null)
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
/* the card folds away the way the graph's display menu does: a click
outside it (the scene'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
}
/* switching the auto height off prefills the manual bounds with the range
the surfaces were drawn at, so the scene never jumps on the toggle */
function toggleZAuto() {
if (space.zAuto) {
space.zMin = Math.round(drawnZlo.value * 1000) / 1000
space.zMax = Math.round(drawnZhi.value * 1000) / 1000
}
space.zAuto = !space.zAuto
}
/* a bound of the auto height, spelled the way the card reads it: three
decimals plain, a power of ten when the number outgrows that */
function bound(v: number): string {
const s = !Number.isFinite(v)
? '—'
: v === 0
? '0'
: Math.abs(v) >= 1e5 || Math.abs(v) < 1e-3
? v.toExponential(2)
: String(Math.round(v * 1000) / 1000)
return s.replace('-', '\u2212')
}
/* the space is the reader's own too: every edit repaints the scene, and
the setting is kept between visits — the unfinished number typed into
a field persists as it was typed and simply draws as nothing */
function saveSpace() {
try {
localStorage.setItem(SPACE_KEY, JSON.stringify(space))
} catch {
/* private mode etc. — the space just won't persist */
}
}
watch(space, () => {
requestDraw()
saveSpace()
})
function restoreSpace() {
try {
const raw = localStorage.getItem(SPACE_KEY)
if (!raw) return
const stored: unknown = JSON.parse(raw)
if (typeof stored !== 'object' || stored === null) return
const s = stored as Partial<Record<keyof typeof space, unknown>>
for (const key of ['xMin', 'xMax', 'yMin', 'yMax', 'zMin', 'zMax', 'subdiv'] as const) {
if (typeof s[key] === 'number') space[key] = s[key] as number
}
if (typeof s.zAuto === 'boolean') space.zAuto = s.zAuto
} catch {
/* storage unreadable — the default space stands */
}
}
let turning = 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) {
canvasEl.value?.setPointerCapture(e.pointerId)
turning = true
lastPt = localPoint(e)
cursor.value = 'grabbing'
}
function onPointerMove(e: PointerEvent) {
if (!turning) return
const p = localPoint(e)
/* the scene turns with the drag, the way a globe does under the hand */
cam.yaw -= (p.x - lastPt.x) * 0.0062
cam.pitch = Math.min(MAX_PITCH, Math.max(MIN_PITCH, cam.pitch + (p.y - lastPt.y) * 0.0062))
lastPt = p
requestDraw()
}
function onPointerUp() {
turning = false
cursor.value = 'grab'
}
function onWheel(e: WheelEvent) {
e.preventDefault()
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
cam.zoom = Math.min(Math.max(cam.zoom * Math.exp(-dy * step), MIN_ZOOM), MAX_ZOOM)
requestDraw()
}
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
onMounted(() => {
restoreSpace()
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'],
})
document.addEventListener('click', onDocClick)
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
themeObserver?.disconnect()
document.removeEventListener('click', onDocClick)
window.removeEventListener('keydown', onKeydown)
})
/* a new surface, an edited one, a colour or visibility change — the scene
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"
@wheel="onWheel"
@dblclick="resetView"
/>
<div ref="toolsEl" class="plot-tools">
<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>
<button
type="button"
class="panel-icon-btn"
:class="{ open: menuOpen }"
:title="t('calc3d.config')"
:aria-label="t('calc3d.config')"
: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" aria-hidden="true">
<path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3" />
<path d="M14 2v4M8 10v4M16 18v4" />
</svg>
</button>
</div>
<div v-if="menuOpen" class="plot-config" role="group" :aria-label="t('calc3d.config')">
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">x</span>
<span class="plot-config-bounds">
<input
v-model.number="space.xMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'x' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.xMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'x' })"
/>
</span>
</div>
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">y</span>
<span class="plot-config-bounds">
<input
v-model.number="space.yMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'y' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.yMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'y' })"
/>
</span>
</div>
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">z</span>
<span v-if="space.zAuto" class="plot-config-value">{{ bound(drawnZlo) }} … {{ bound(drawnZhi) }}</span>
<span v-else class="plot-config-bounds">
<input
v-model.number="space.zMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'z' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.zMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'z' })"
/>
</span>
</div>
<button
type="button"
class="plot-config-toggle"
role="switch"
:aria-checked="String(space.zAuto)"
@click="toggleZAuto"
>
<span>{{ t('calc3d.configZNote') }}</span>
<span class="plot-config-switch" :class="{ on: space.zAuto }" aria-hidden="true">
<span class="knob" />
</span>
</button>
<div class="plot-config-row">
<span class="plot-config-label">{{ t('calc3d.configSubdivisions') }}</span>
<input
v-model.number="space.subdiv"
class="plot-config-input"
type="number"
min="2"
max="500"
step="1"
autocomplete="off"
:aria-label="t('calc3d.configSubdivisions')"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -5,9 +5,9 @@ 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.
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. */
The wiki's own pages — the link map, the graphing calculator, the Lean
editor — 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()
@@ -29,6 +29,14 @@ const PAGE_TOOLS: { tool: ToolPage; terms: string[] }[] = [
tool: 'calculator',
terms: ['calculator', 'calc', 'plot', 'plotter', 'graphing', 'plane'],
},
{
tool: 'calculator3d',
terms: ['3d', '3d calculator', 'surface', 'space', 'solid', 'scene'],
},
{
tool: 'lean',
terms: ['lean', 'lean4', 'lean 4', 'proof', 'prover', 'theorem prover', 'mathlib'],
},
]
const q = ref('')
@@ -270,6 +278,10 @@ function highlight(text: string): Part[] {
<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-if="p.tool === 'lean'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 4 6.5 20" />
<path d="m10.5 12 7 8" />
</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" />

View File

@@ -1,7 +1,8 @@
<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 ×
link map, a calculator, the Lean editor — 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.
@@ -68,6 +69,15 @@ function turnToThisPage() {
<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-if="tool === 'calculator3d'" 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="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<svg v-else-if="tool === 'lean'" 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="M14.5 4 6.5 20" />
<path d="m10.5 12 7 8" />
</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" />
@@ -78,6 +88,8 @@ function turnToThisPage() {
<div class="pane-body" :inert="active ? null : ''">
<div class="pane-stage">
<GraphStage v-if="tool === 'graph'" />
<Calculator3DStage v-else-if="tool === 'calculator3d'" />
<LeanWorkbench v-else-if="tool === 'lean'" />
<CalculatorStage v-else />
</div>
</div>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { VarSlider } from '~/composables/useVarSliders'
/* the sliders one row wears: each free variable gets a line — the name in
the maths' own face, a drag for the rough and a field for the fine.
Normally hung just beneath the row; a row that only declares a variable
(the "k" or "k = 3" that creates one) instead wears the slider in place of
its text once the field rests — pass `inline` to lay it inside the row,
and `inputFor` (the id of that declaration's field, kept mounted behind
the slider) so a click on the name wakes the text to edit it again.
Shared by both calculators; the logic behind the values lives in
useVarSliders. */
defineProps<{ sliders: VarSlider[]; inline?: boolean; inputFor?: string }>()
const emit = defineEmits<{ set: [name: string, value: number] }>()
/* how finely a slider moves, dragged or typed */
const STEP = 0.1
const { t } = useI18n()
</script>
<template>
<ul
v-if="sliders.length"
class="calc-vars"
:class="{ 'is-inline': inline }"
:aria-label="t('calc.variablesPanel')"
>
<li v-for="s in sliders" :key="s.name" class="calc-var">
<!-- a declaration row's slider wears its name as a door back to the
field behind it: clicking it wakes "k = 3" to be edited again -->
<label
v-if="inputFor"
class="calc-var-name editable"
:for="inputFor"
:title="t('calc.editVariable', { name: s.name })"
>{{ s.name }}</label>
<span v-else class="calc-var-name">{{ s.name }}</span>
<input
type="range"
class="calc-var-range"
:min="s.min"
:max="s.max"
:step="STEP"
:value="s.value"
:aria-label="t('calc.variableValue', { name: s.name })"
@input="emit('set', s.name, ($event.target as HTMLInputElement).valueAsNumber)"
/>
<input
type="number"
class="calc-var-number"
:step="STEP"
:value="s.value"
:aria-label="t('calc.variableValue', { name: s.name })"
@change="emit('set', s.name, ($event.target as HTMLInputElement).valueAsNumber)"
/>
</li>
</ul>
</template>

View File

@@ -4,8 +4,9 @@ export interface ArticleTab {
/** the article this page is of — its canonical slug, and the page's key */
identity: string
/** set when the page is one of the wiki's own pages — the link map, the
* graphing calculator — laid on the book instead of an article: the page
* draws that live surface, at its own address, for as long as it is open */
* graphing calculator, the Lean editor — laid on the book instead of an
* article: the page draws that live surface, at its own address, for as
* long as it is open */
tool?: ToolPage
/** the language this page is turned to */
lang: string
@@ -24,6 +25,8 @@ const MAX_TABS = 10
export const TOOL_LABEL_KEYS: Record<ToolPage, string> = {
graph: 'sidebar.graph',
calculator: 'sidebar.calculator',
calculator3d: 'sidebar.calculator3d',
lean: 'sidebar.lean',
}
/** The key of the book page holding one of the wiki's own pages. No article
@@ -123,9 +126,10 @@ export function useArticleTabs() {
}
/**
* Lay one of the wiki's own pages — the link map, the graphing calculator —
* on the book beside the articles. One surface is one page however many
* times it is asked for: asking again just finds the page already there.
* Lay one of the wiki's own pages — the link map, the graphing calculator,
* the Lean editor — on the book beside the articles. One surface is one
* page however many times it is asked for: asking again just finds the
* page already there.
*/
function openTool(page: ToolPage) {
const identity = toolPageIdentity(page)

View File

@@ -0,0 +1,287 @@
/* The wiki's Lean session (client-side).
*
* One websocket to the lean4web server (`/websocket/<project>`), through
* which the wiki plays the part of a Lean editor's language client: open a
* document, send edits, ask for the goal at the cursor (the server's
* `$/lean/plainGoal`), and read the diagnostics — whose severity-3 entries
* are Lean's `#check`/`#eval` style messages, shown in the Messages pane.
*
* The session lives at module scope on purpose: the book may move the Lean
* page between its full-page and pane hosts (unmounting the editor), but a
* running Lean elaboration and its open document are expensive to restart —
* so they outlive the component, and the editor simply re-attaches. */
import { RpcSocket } from '~/utils/lean/lsp'
export type LeanPhase = 'idle' | 'connecting' | 'initializing' | 'ready' | 'lost'
export interface LeanInfoMessage { line: number, text: string }
/* the shape the Lean server publishes per diagnostic (a slice of LSP) */
export interface LeanDiagnostic {
range: { start: { line: number, character: number }, end: { line: number, character: number } }
severity?: number
message: string
source?: string
}
const DOCUMENT_NAME = 'Main.lean'
const FALLBACK_PROJECT = 'MathlibDemo'
interface LeanSession {
phase: Ref<LeanPhase>
goals: Ref<string[]>
messages: Ref<LeanInfoMessage[]>
busy: Ref<boolean>
version: Ref<string | null>
error: Ref<string | null>
text: Ref<string>
/* the uri the lean server knows this buffer by — LSP requests must
* name the document the server opened, not the editor's local one */
serverDocUri: Ref<string>
start: () => Promise<void>
retry: () => void
change: (text: string) => void
cursorAt: (line: number, character: number) => void
request: <T>(method: string, params?: unknown) => Promise<T>
onDiagnostics: (cb: (diags: LeanDiagnostic[]) => void) => () => void
}
let singleton: LeanSession | null = null
export function useLeanSession(): LeanSession {
const config = useRuntimeConfig()
const leanUrl = String(config.public.leanUrl || '')
if (!singleton && import.meta.client && leanUrl) singleton = createSession(leanUrl)
/* a dormant stand-in keeps the component honest on the server, where
* there is nothing to speak to */
return singleton ?? {
phase: ref('idle') as Ref<LeanPhase>,
goals: ref([]) as Ref<string[]>,
messages: ref([]) as Ref<LeanInfoMessage[]>,
busy: ref(false) as Ref<boolean>,
version: ref(null) as Ref<string | null>,
error: ref(null) as Ref<string | null>,
text: ref('') as Ref<string>,
serverDocUri: ref('') as Ref<string>,
start: async () => {},
retry: () => {},
change: () => {},
cursorAt: () => {},
request: async () => { throw new Error('no lean session') },
onDiagnostics: () => () => {},
}
}
function createSession(leanUrl: string): LeanSession {
const phase = ref<LeanPhase>('idle')
const goals = ref<string[]>([])
const messages = ref<LeanInfoMessage[]>([])
const busy = ref(false)
const version = ref<string | null>(null)
const error = ref<string | null>(null)
const text = ref('')
const serverDocUri = ref('')
let rpc: RpcSocket | null = null
let docUri = `${FALLBACK_PROJECT}/${DOCUMENT_NAME}`
let sentVersion = 0
let lastSentText = ''
const cursor = { line: 0, character: 0 }
const diagListeners = new Set<(diags: LeanDiagnostic[]) => void>()
let lastDiags: LeanDiagnostic[] = []
let changeTimer: ReturnType<typeof setTimeout> | undefined
let goalTimer: ReturnType<typeof setTimeout> | undefined
const wsBase = leanUrl.replace(/\/+$/, '').replace(/^http/, 'ws')
async function pickProject(): Promise<string> {
try {
// the server's REST endpoints carry no CORS headers — probing them from
// another origin only earns a blocked request, so stay quiet cross-origin
// and name the canonical project (the websocket is CORS-free)
if (new URL(leanUrl, window.location.href).origin !== window.location.origin) {
return FALLBACK_PROJECT
}
const res = await fetch(`${leanUrl.replace(/\/+$/, '')}/api/projects`)
const projects = (await res.json()) as Array<{ folder: string, config?: { default?: boolean, hidden?: boolean } }>
const visible = projects.filter(p => !p.config?.hidden)
return (visible.find(p => p.config?.default) ?? visible[0])?.folder || FALLBACK_PROJECT
}
catch { return FALLBACK_PROJECT }
}
function handleNotification(method: string, params: unknown) {
const p = params as Record<string, any> | undefined
if (method === '$/lean/fileProgress') {
busy.value = Array.isArray(p?.processing) && p.processing.length > 0
return
}
if (method === 'textDocument/publishDiagnostics') {
const raw = Array.isArray(p?.diagnostics) ? p.diagnostics as LeanDiagnostic[] : []
// the server marks some diagnostics silent (elaboration noise); the
// wiki shows errors/warnings in the editor, info in the Messages pane
const diags = raw.filter(d => !('isSilent' in d && (d as any).isSilent))
lastDiags = diags
messages.value = diags
.filter(d => d.severity === 3)
.map(d => ({ line: d.range.start.line + 1, text: d.message }))
for (const cb of diagListeners) cb(diags)
}
}
async function connect() {
phase.value = 'connecting'
error.value = null
const project = await pickProject()
docUri = `${project}/${DOCUMENT_NAME}`
// a fresh document identity means the buffer below re-sends as new
sentVersion = 0
lastSentText = ''
try {
rpc = new RpcSocket(`${wsBase}/websocket/${project}`, {
onNotification: handleNotification,
onClose: () => {
if (phase.value !== 'idle' && phase.value !== 'lost') {
phase.value = 'lost'
busy.value = false
}
},
})
await rpc.whenOpen
phase.value = 'initializing'
const init = await rpc.request<{
serverInfo?: { name?: string, version?: string }
}>('initialize', {
processId: null,
clientInfo: { name: 'mathew', version: '0.1.0' },
rootUri: '',
capabilities: {
general: { positionEncodings: ['utf-16'] },
workspace: { configuration: false, workspaceFolders: false, applyEdit: true },
window: {
showMessage: {},
showDocument: { support: true },
workDoneProgress: true,
configuration: true,
},
textDocument: {
synchronization: { didChange: true, willSave: false, didSave: false },
publishDiagnostics: { relatedInformation: true, versionSupport: true },
hover: { contentFormat: ['markdown', 'plaintext'] },
definition: { linkSupport: true },
documentHighlight: {},
completion: {
completionItem: {
snippetSupport: true,
documentationFormat: ['markdown', 'plaintext'],
insertTextFormat: { valueSet: [1, 2] },
additionalTextEditsSupport: true,
},
contextSupport: true,
},
},
},
initializationOptions: { hasWidgets: true },
})
version.value = init.serverInfo?.version ?? null
rpc.notify('initialized', {})
rpc.notify('textDocument/didOpen', {
textDocument: { uri: docUri, languageId: 'lean4', version: 1, text: text.value },
})
sentVersion = 1
lastSentText = text.value
phase.value = 'ready'
void refreshGoal()
}
catch (err) {
error.value = err instanceof Error ? err.message : String(err)
phase.value = 'lost'
}
}
async function refreshGoal() {
if (!rpc || phase.value !== 'ready') return
try {
const res = await rpc.request<{ goals?: string[], rendered?: string } | null>(
'$/lean/plainGoal',
{ textDocument: { uri: docUri }, position: cursor },
)
// the server answers `null` away from tactic goals; `rendered` is the
// goals as a markdown fence — the array is the honest plain text
goals.value = res?.goals ?? []
}
catch { /* raced by a newer edit (ContentModified) — the next ask wins */ }
}
function sendChange() {
if (!rpc || phase.value !== 'ready' || text.value === lastSentText) return
sentVersion += 1
// LSP allows one change spanning the whole document — simple and exact
const endLine = Math.max(lastSentText.split('\n').length - 1, 0)
const endChar = lastSentText.slice(lastSentText.lastIndexOf('\n') + 1).length
rpc.notify('textDocument/didChange', {
textDocument: { uri: docUri, version: sentVersion },
contentChanges: [{
range: { start: { line: 0, character: 0 }, end: { line: endLine, character: endChar } },
text: text.value,
}],
})
lastSentText = text.value
void refreshGoal()
}
return {
phase,
goals,
messages,
busy,
version,
error,
text,
async start() {
if (phase.value !== 'idle') return
await connect()
},
retry() {
try { rpc?.close() }
catch { /* gone */ }
rpc = null
goals.value = []
messages.value = []
busy.value = false
void connect()
},
change(newText) {
text.value = newText
clearTimeout(changeTimer)
changeTimer = setTimeout(sendChange, 400)
},
cursorAt(line, character) {
cursor.line = line
cursor.character = character
clearTimeout(goalTimer)
goalTimer = setTimeout(() => void refreshGoal(), 200)
},
request<T>(method: string, params?: unknown) {
if (!rpc || phase.value !== 'ready') return Promise.reject(new Error('lean is not ready'))
return rpc.request<T>(method, params)
},
onDiagnostics(cb) {
diagListeners.add(cb)
// a freshly (re)attached editor wants the current marks at once
cb(lastDiags)
return () => diagListeners.delete(cb)
},
}
}

View File

@@ -0,0 +1,119 @@
/* The free-variable sliders both calculators wear: one value per variable
in a scope the compile pass reads, one slider per variable anchored under
the row that claims it — the row declaring it ("k", "k = 2") when one
asks, else the first row speaking it — and the values kept between visits
per calculator. A name the scope does not carry yet reads as 1, where
every slider starts. */
export interface VarSlider {
name: string
value: number
min: number
max: number
}
/* one row's reading, as far as sliders care: the variables it speaks and
the declaration its text claims (a null start says "k", not "k = 2") */
export interface RowVarDecls {
rowId: number
vars: string[]
declared: { name: string; value: number | null } | null
}
/* how far a slider reaches either side of zero; a value set past an end —
by "k = 40" or by typing — pulls that end out so the thumb can reach it */
const SLIDER_MIN = -10
const SLIDER_MAX = 10
export function useVarSliders(
varValues: Record<string, number>,
decls: () => RowVarDecls[],
storageKey: string,
) {
/* every variable spoken, with the value its "k = 2" row asks to start at
— or 1, where sliders start. One variable wears one slider however many
rows speak it. */
const varStarts = computed(() => {
const starts = new Map<string, number>()
for (const d of decls()) {
if (d.declared && !starts.has(d.declared.name)) starts.set(d.declared.name, d.declared.value ?? 1)
for (const name of d.vars) if (!starts.has(name)) starts.set(name, 1)
}
return starts
})
// a variable the rows only now started speaking takes its start value;
// one the reader already moved keeps the value they left it at. Filling
// from the first pass keeps what the server draws and what the reader
// finds after mounting the same
function fillDefaults() {
for (const [name, value] of varStarts.value) {
if (!(name in varValues)) varValues[name] = value
}
}
watch(varStarts, fillDefaults, { immediate: true })
/* one slider per variable, filed under the row it sits beneath: the row
claiming it ("k", "k = 2") wins, else the first row that speaks it */
const slidersByRow = computed(() => {
const anchor = new Map<string, number>()
for (const d of decls()) {
if (d.declared && !anchor.has(d.declared.name)) anchor.set(d.declared.name, d.rowId)
}
for (const d of decls()) {
for (const name of d.vars) {
if (!anchor.has(name)) anchor.set(name, d.rowId)
}
}
const byRow = new Map<number, VarSlider[]>()
for (const d of decls()) {
for (const name of d.vars) {
if (anchor.get(name) !== d.rowId) continue
const value = varValues[name] ?? 1
const list = byRow.get(d.rowId) ?? []
list.push({ name, value, min: Math.min(SLIDER_MIN, value), max: Math.max(SLIDER_MAX, value) })
byRow.set(d.rowId, list)
}
}
return byRow
})
function setVar(name: string, raw: number) {
if (Number.isFinite(raw)) varValues[name] = raw
}
/* the values keep themselves — only the ones a row still speaks, so a
variable nobody asks for anymore is forgotten on the next save rather
than haunting the store */
function save() {
try {
const kept: Record<string, number> = {}
for (const [name, value] of Object.entries(varValues)) {
if (varStarts.value.has(name) && Number.isFinite(value)) kept[name] = value
}
localStorage.setItem(storageKey, JSON.stringify(kept))
} catch {
/* private mode etc. — the slider values just won't persist */
}
}
watch([varValues, varStarts], save)
onMounted(() => {
try {
const raw = localStorage.getItem(storageKey)
if (raw) {
const stored: unknown = JSON.parse(raw)
if (typeof stored === 'object' && stored !== null) {
for (const [name, value] of Object.entries(stored)) {
if (typeof value === 'number' && Number.isFinite(value)) varValues[name] = value
}
}
}
} catch {
/* storage unreadable — the sliders start where their rows ask */
}
fillDefaults()
})
return { slidersByRow, setVar }
}

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
/* The 3D calculator's own address. Usually the space fills the page here;
but when the book has this page open — laid on it from the search popup,
beside the open articles — the pane holds the instance, and this host
says nothing over it (the way the article route lets the book draw its
pages). */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const route = useRoute()
const { t } = useI18n()
const { tabs } = useArticleTabs()
const heldByBook = computed(() => tabAt(tabs.value, route.path)?.tool != null)
useSeoMeta({
title: () => t('calc3d.metaTitle'),
description: () => t('calc3d.metaDescription'),
})
</script>
<template>
<Calculator3DStage v-if="!heldByBook" />
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
/* The Lean editor's own address. Usually the editor fills the page here;
but when the book has this page open — laid on it from the sidebar or
the search popup, beside the open articles — the pane holds the live
session, and this host says nothing over it (the way the article route
lets the book draw its pages). The editor itself is the wiki's own
(`LeanWorkbench`): Monaco loads client-side, so the route is SSR-safe. */
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
const route = useRoute()
const { t } = useI18n()
const { tabs } = useArticleTabs()
const heldByBook = computed(() => tabAt(tabs.value, route.path)?.tool != null)
useSeoMeta({
title: () => t('lean.metaTitle'),
description: () => t('lean.metaDescription'),
})
</script>
<template>
<LeanWorkbench v-if="!heldByBook" />
</template>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,252 @@
/* The Lean 4 language for Monaco — registered once, client-side.
*
* A Monarch tokenizer (keywords, tactics, commands, the nested `/- -/`
* comments Lean uses for docstrings) plus themes built from the wiki's live
* palette, so the editor matches the page around it and follows the wiki's
* theme *and* accent switches live: the accent — caret, selection, active
* line number, keywords — is sampled from the `--accent` custom properties
* that `[data-accent]` swaps. (Lean's own server also offers semantic
* highlighting; keyword colours are the honest first pass — see the note in
* lean/README.) */
import * as monaco from 'monaco-editor'
export const LEAN_LANGUAGE = 'lean4'
export const LEAN_THEMES = { light: 'mathew-light', dark: 'mathew-dark' } as const
/* words that introduce declarations, terms, and structuring */
const keywords = [
'abstract', 'axiom', 'by', 'cases', 'catch', 'class', 'command', 'deriving', 'do',
'else', 'end', 'example', 'expansion', 'explicit', 'export', 'def', 'theorem',
'lemma', 'corollary', 'proposition', 'instance', 'structure', 'inductive', 'abbrev',
'mutual', 'namespace', 'section', 'open', 'protected', 'private', 'partial',
'noncomputable', 'set_option', 'unset_option', 'option', 'attribute', 'variable',
'variables', 'include', 'hiding', 'renaming', 'as', 'where', 'from', 'show',
'have', 'suffices', 'let', 'in', 'if', 'then', 'match', 'with', 'without', 'fun',
'forall', 'at', 'using', 'via', 'override', 'obsolete', 'inherit', 'field',
'generalizing', 'replacing', 'renaming', 'specializing', 'unshuffling', 'case',
'dive', 'guard', 'modifies', 'macro', 'macro_rules', 'syntax', 'elab', 'elab_rules',
'notation', 'infix', 'infixl', 'infixr', 'prefix', 'postfix', 'binders',
'declare_syntax_cat', 'syntax_cat', 'extend', 'scoped', 'unused', 'auto_unfold',
'extern', 'implemented_by', 'native_decide', 'lift', 'coe', 'coe_struct',
'classical', 'decidable', 'universe', 'universes', 'exit',
]
/* tactics — the words after `by` and inside tactic blocks */
const tactics = [
'intro', 'intros', 'exact', 'apply', 'refine', 'have', 'obtain', 'use', 'exists',
'constructor', 'left', 'right', 'simp', 'simp_all', 'simp_rw', 'rw', 'rewrite',
'show', 'conv', 'calc', 'ring', 'ring_nf', 'field_simp', 'norm_num', 'omega',
'linarith', 'nlinarith', 'positivity', 'decide', 'trivial', 'rfl', 'refl', 'symm',
'trans', 'ext', 'funext', 'congr', 'congr!', 'cases', 'induction', 'rcases',
'obtain', 'subst', 'contradiction', 'exfalso', 'push_neg', 'contrapose',
'constructor', 'aesop', 'tauto', 'bv_decide', 'gcongr', 'change', 'generalize',
'revert', 'clear', 'rename_i', 'next', 'any_goals', 'all_goals', 'focus', 'dotl',
'by_contra', 'by_contra!', 'apply_fun', 'congr_arg', 'congrFun', 'congrArg',
'simpa', 'simpa using', 'norm_cast', 'exact?', 'apply?', 'sorry', 'native_decide',
'unfold', 'dsimp', 'unfold_projs', 'whisker', 'thm_to_tactic', 'tauto', 'mt',
'absurd', 'injection', 'no_index', 'guard_goals', 'expect_failure', 'stop', 'skip',
'trace', 'log', 'sleep', 'wait', 'recover', 'error',
]
const commands = ['#check', '#eval', '#reduce', '#synth', '#print', '#exit', '#where', '#hole', '#why', '#loogle', '#guard']
let registered = false
export function registerLeanLanguage() {
if (registered) return
registered = true
monaco.languages.register({ id: LEAN_LANGUAGE })
monaco.languages.setLanguageConfiguration(LEAN_LANGUAGE, {
comments: { lineComment: '--', blockComment: ['/-', '-/'] },
brackets: [
['{', '}'], ['[', ']'], ['(', ')'],
['⟨', '⟩'], ['⟦', '⟧'], ['‹', '›'], ['⦃', '⦄'], ['⁅', '⁆'], ['⌈', '⌉'], ['⌊', '⌋'],
],
autoClosingPairs: [
{ open: '{', close: '}' }, { open: '[', close: ']' }, { open: '(', close: ')' },
{ open: '"', close: '"' }, { open: '/-', close: '-/' },
{ open: '⟨', close: '⟩' }, { open: '⟦', close: '⟧' }, { open: '‹', close: '›' },
],
surroundingPairs: [
{ open: '{', close: '}' }, { open: '[', close: ']' }, { open: '(', close: ')' },
{ open: '"', close: '"' }, { open: '⟨', close: '⟩' }, { open: '⟦', close: '⟧' },
{ open: '‹', close: '›' },
],
indentationRules: {
increaseIndentPattern: /^\s*(\bby\b|\bdo\b|\bthen\b|\bcalc\b|\{[^}]*$|\[[^\]]*$|\([^)]*$)/,
decreaseIndentPattern: /^\s*(\bend\b|\}|\\|\)|\/-})/,
},
})
monaco.languages.setMonarchTokensProvider(LEAN_LANGUAGE, {
keywords,
tactics,
commands,
tokenizer: {
root: [
[/--.*$/, 'comment'],
[/\/-/, { token: 'comment', next: '@blockcomment' }],
[/"/, { token: 'string', next: '@string' }],
[/[0-9]+\.[0-9]+([eE][-+]?[0-9]+)?/, 'number.float'],
[/[0-9]+([eE][-+]?[0-9]+)?[nNzQqR]?/, 'number'],
[
// one identifier run (ascii word, or a unicode run — Lean names
// carry α, →, ≤, ⁻¹), then decided against the word lists
/[a-zA-Z_#][a-zA-Z0-9_?!'∆∇]*|[^\x00-\x7F][a-zA-Z0-9_?!'∆∇]*/,
{
cases: {
'@commands': 'keyword.command',
'@tactics': 'keyword.tactic',
'@keywords': 'keyword',
'@default': 'identifier',
},
},
],
[/^\s*@\[[^\]]*\]/, 'annotation'],
[/[→←↔∧∨¬∀∃≠≤≥<>!=+\-*/^|&~:;.,@#][=]?/, 'operator'],
],
blockcomment: [
[/\/-/, { token: 'comment', next: '@blockcomment' }],
[/-\//, { token: 'comment', next: '@pop' }],
[/[^\-\/]+/, 'comment'],
[/[\/-]/, 'comment'],
],
string: [
[/[^\\"]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, { token: 'string', next: '@pop' }],
],
},
})
}
/* ---------- the wiki's palette, read live, as editor themes ---------- */
/* Monaco themes are plain hex, not CSS variables, so the accent is sampled
* off the document each time a theme is built — the same read the canvas
* surfaces make. The fallbacks are violet, the wiki's default accent. */
function wikiAccent(dark: boolean) {
const css = getComputedStyle(document.documentElement)
const at = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback
return {
accent: at('--accent', dark ? '#7c8cff' : '#4f5fe8'),
strong: at('--accent-strong', dark ? '#99a5ff' : '#3d4dd6'),
second: at('--accent-2', dark ? '#38d3ee' : '#0ea5c9'),
}
}
/* `#rrggbbaa` — Monaco's translucent form, standing in for the wiki's
* `color-mix`. Shorthand hex is widened; anything else passes through. */
function alpha(hex: string, a: string) {
if (/^#[0-9a-f]{3}$/i.test(hex)) return `#${hex.slice(1).split('').map(c => c + c).join('')}${a}`
return /^#[0-9a-f]{6}$/i.test(hex) ? `${hex}${a}` : hex
}
/* token rules take hex without the leading `#` */
function ink(hex: string) {
return hex.replace(/^#/, '')
}
/* Themes are named by their palette (mathew-dark-fb7185…): a new accent
* yields a fresh theme rather than one Monaco may have cached under an old
* palette, and a reader cycling back reuses a definition already made. */
const definedThemes = new Set<string>()
/* the theme matching the wiki right now — [data-theme] picks the surfaces,
* [data-accent] the caret, selection, active line number and keywords */
export function currentLeanTheme(): string {
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
const a = wikiAccent(dark)
const name = `${dark ? LEAN_THEMES.dark : LEAN_THEMES.light}-${ink(a.accent)}-${ink(a.strong)}-${ink(a.second)}`.toLowerCase()
if (definedThemes.has(name)) return name
definedThemes.add(name)
monaco.editor.defineTheme(name, dark
? {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: ink(a.strong) },
{ token: 'keyword.tactic', foreground: '38d3ee' },
{ token: 'keyword.command', foreground: ink(a.strong), fontStyle: 'bold' },
{ token: 'string', foreground: '3fce8b' },
{ token: 'number', foreground: 'f5a83c' },
{ token: 'comment', foreground: '6b7488', fontStyle: 'italic' },
{ token: 'annotation', foreground: '99a1b5' },
{ token: 'operator', foreground: '99a1b5' },
{ token: 'type.identifier', foreground: '38d3ee' },
],
colors: {
'editor.background': '#141826',
'editor.foreground': '#e7eaf3',
'editor.lineHighlightBackground': '#1a2030',
'editorLineNumber.foreground': '#6b7488',
'editorLineNumber.activeForeground': a.accent,
'editorCursor.foreground': a.accent,
'editor.selectionBackground': alpha(a.accent, '40'),
'editor.selectionHighlightBackground': alpha(a.accent, '1f'),
'editorIndentGuide.background1': '#232a3c',
'editorIndentGuide.activeBackground1': '#313a52',
'editorWhitespace.foreground': '#313a52',
'editorWidget.background': '#10131c',
'editorWidget.border': '#232a3c',
'editorSuggestWidget.selectedBackground': '#1a2030',
'editorHoverWidget.background': '#10131c',
'editorHoverWidget.border': '#232a3c',
'scrollbarSlider.background': '#e7eaf329',
'scrollbarSlider.hoverBackground': '#e7eaf347',
'scrollbarSlider.activeBackground': '#e7eaf366',
'editorError.foreground': '#f47085',
'editorWarning.foreground': '#f5a83c',
'editorInfo.foreground': a.second,
},
}
: {
base: 'vs',
inherit: true,
rules: [
{ token: 'keyword', foreground: ink(a.strong) },
{ token: 'keyword.tactic', foreground: '0891b2' },
{ token: 'keyword.command', foreground: ink(a.strong), fontStyle: 'bold' },
{ token: 'string', foreground: '189a5c' },
{ token: 'number', foreground: 'd97706' },
{ token: 'comment', foreground: '8a93a8', fontStyle: 'italic' },
{ token: 'annotation', foreground: '5b6478' },
{ token: 'operator', foreground: '5b6478' },
{ token: 'type.identifier', foreground: '0891b2' },
],
colors: {
'editor.background': '#ffffff',
'editor.foreground': '#1b2130',
'editor.lineHighlightBackground': '#eef1f7',
'editorLineNumber.foreground': '#8a93a8',
'editorLineNumber.activeForeground': a.accent,
'editorCursor.foreground': a.accent,
'editor.selectionBackground': alpha(a.accent, '33'),
'editor.selectionHighlightBackground': alpha(a.accent, '1a'),
'editorIndentGuide.background1': '#e2e6ef',
'editorIndentGuide.activeBackground1': '#cfd5e3',
'editorWhitespace.foreground': '#cfd5e3',
'editorWidget.background': '#ffffff',
'editorWidget.border': '#e2e6ef',
'editorSuggestWidget.selectedBackground': '#eef1f7',
'editorHoverWidget.background': '#ffffff',
'editorHoverWidget.border': '#e2e6ef',
'scrollbarSlider.background': '#1b213024',
'scrollbarSlider.hoverBackground': '#1b21304d',
'scrollbarSlider.activeBackground': '#1b213066',
'editorError.foreground': '#d1344b',
'editorWarning.foreground': '#d97706',
'editorInfo.foreground': a.second,
},
})
return name
}
/* move the editor to the palette the wiki is wearing right now */
export function applyLeanTheme() {
monaco.editor.setTheme(currentLeanTheme())
}

View File

@@ -0,0 +1,116 @@
/* A very small JSON-RPC 2.0 client over WebSocket — the dialect the lean4web
* server forwards between the browser and a real Lean language server
* (`lake serve`): one JSON message per frame, in either direction.
*
* The wiki is a deliberate minimal client: it opens one document, tracks the
* cursor, and asks for goals. The Lean server nonetheless sends a few
* *requests* its way — registering file watchers, asking to refresh caches,
* probing for configuration. There is nothing to register or refresh here,
* so those are acknowledged with `null` (a `workspace/configuration` probe
* with `null` per item) rather than left hanging. */
export interface RpcError {
code: number
message: string
data?: unknown
}
interface RpcMessage {
jsonrpc: '2.0'
id?: number | string
method?: string
params?: unknown
result?: unknown
error?: RpcError
}
export class RpcSocket {
private ws: WebSocket
private nextId = 1
private pending = new Map<number, { resolve: (v: unknown) => void, reject: (e: Error) => void }>()
private closed = false
readonly whenOpen: Promise<void>
constructor(
url: string,
private handlers: {
onNotification?: (method: string, params: unknown) => void
onClose?: (info: { clean: boolean }) => void
} = {},
) {
this.ws = new WebSocket(url)
this.whenOpen = new Promise((resolve, reject) => {
this.ws.addEventListener('open', () => resolve(), { once: true })
this.ws.addEventListener('error', () => reject(new Error('websocket failed to open')), { once: true })
})
this.ws.addEventListener('message', (ev: MessageEvent) => {
let msg: RpcMessage
try {
msg = JSON.parse(String(ev.data))
}
catch { return }
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return
if (msg.id !== undefined && msg.method) { this.answerServer(msg); return }
if (msg.id !== undefined) {
const waiter = this.pending.get(Number(msg.id))
if (!waiter) return
this.pending.delete(Number(msg.id))
if (msg.error) waiter.reject(new Error(`${msg.method ?? 'request'}: ${msg.error.message}`))
else waiter.resolve(msg.result)
return
}
if (msg.method) this.handlers.onNotification?.(msg.method, msg.params)
})
this.ws.addEventListener('close', (ev: CloseEvent) => {
this.closed = true
const err = new Error('lean server closed the connection')
for (const waiter of this.pending.values()) waiter.reject(err)
this.pending.clear()
this.handlers.onClose?.({ clean: ev.wasClean })
})
}
get open() {
return this.ws.readyState === WebSocket.OPEN
}
async request<T>(method: string, params?: unknown): Promise<T> {
if (this.closed || this.ws.readyState !== WebSocket.OPEN) throw new Error('not connected to the lean server')
const id = this.nextId++
const wire: RpcMessage = { jsonrpc: '2.0', id, method }
if (params !== undefined) wire.params = params
this.ws.send(JSON.stringify(wire))
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
})
}
notify(method: string, params?: unknown) {
if (this.closed || this.ws.readyState !== WebSocket.OPEN) return
const wire: RpcMessage = { jsonrpc: '2.0', method }
if (params !== undefined) wire.params = params
this.ws.send(JSON.stringify(wire))
}
close() {
this.closed = true
try { this.ws.close() }
catch { /* already gone */ }
}
/* capability registrations, cache refreshes, configuration probes: the
* honest answer from a single-document client is "nothing to do" */
private answerServer(msg: RpcMessage) {
let result: unknown = null
if (msg.method === 'workspace/configuration') {
const items = (msg.params as { items?: unknown[] } | undefined)?.items
result = Array.isArray(items) ? items.map(() => null) : null
}
if (this.closed || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }))
}
}

View File

@@ -0,0 +1,8 @@
/* Monaco's editor worker, wrapped so the `?worker` import stays static
* (what this toolchain resolves) while the component still pulls it in
* lazily, only in the browser. Monaco 0.57's exports map makes paths like
* `monaco-editor/esm/vs/...` invalid — the package-root form below is the
* real specifier (`./editor/*` → `./esm/vs/editor/*`). */
import EditorWorker from 'monaco-editor/editor/editor.worker.js?worker'
export { EditorWorker }

View File

@@ -10,6 +10,14 @@
everything it accepts is a real function of x or a real relation in the
plane, and everything else is refused with a reason.
A name that is not x, y, one of the constants, or a called function is a
free variable — "k", "a", "radius" — and asks for a slider: the compiled
answer names the variables it speaks, and evaluation reads their values
from the scope it is handed. So an expression with a variable means the
family of curves those values trace. "k" alone, or "k = 2" ahead of a
plain number, is the declaration itself: it claims the slider and plots
nothing of its own.
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
@@ -20,24 +28,39 @@ export type PlotFunction = (x: number) => number
/* a relation of the plane: the curve is its zero set */
export type PlaneFunction = (x: number, y: number) => number
/* a surface of space: the height z above the point (x, y) */
export type SurfaceFunction = (x: number, y: number) => number
/* the values a free name answers with — one slider per variable. A name the
scope does not carry yet reads as 1, where a fresh slider starts */
export type VarScope = Record<string, 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; kind: 'function'; fn: PlotFunction }
| { ok: true; kind: 'relation'; fn: PlaneFunction }
// "(x, y)" or "P = (x, y)": a single dot at fixed coordinates, optionally named
| { ok: true; kind: 'point'; x: number; y: number; label: string | null }
// each kind carries the free variables it speaks, in order of appearance
| { ok: true; kind: 'function'; vars: string[]; fn: PlotFunction }
| { ok: true; kind: 'relation'; vars: string[]; fn: PlaneFunction }
// "(x, y)" or "P = (x, y)": a single dot at fixed coordinates, optionally
// named. Coordinates may ride the sliders, so the dot moves with them
| { ok: true; kind: 'point'; vars: string[]; x: number; y: number; label: string | null }
// "k" or "k = 2": claims a slider. A null value says the text asked for
// one without saying where it starts. Plots nothing of its own
| { ok: true; kind: 'variable'; vars: string[]; name: string; value: number | null }
| { ok: false; reason: PlotErrorReason }
type Op = '+' | '-' | '*' | '/' | '%' | '^' | '=' | '(' | ')' | ','
type Token = { t: Op } | { t: 'num'; value: number } | { t: 'id'; name: string }
/* a number carries the text it was read from: the typesetter prints that,
so a half-typed decimal keeps the digits exactly as they stand */
type Token = { t: Op } | { t: 'num'; value: number; raw: string } | { t: 'id'; name: string }
type Node =
| { kind: 'num'; value: number }
| { kind: 'var'; name: 'x' | 'y' }
| { kind: 'num'; value: number; raw?: string }
// x and y are the plane's coordinates; every other name is a slider
| { kind: 'var'; name: string }
| { kind: 'const'; value: number }
| { kind: 'neg'; arg: Node }
| { kind: 'bin'; op: '+' | '-' | '*' | '/' | '%' | '^'; a: Node; b: Node }
@@ -132,9 +155,10 @@ function tokenize(src: string): Token[] {
i++
} else break
}
const value = Number.parseFloat(s.slice(start, i))
const text = s.slice(start, i)
const value = Number.parseFloat(text)
if (!Number.isFinite(value)) throw new PlotParseError('syntax')
toks.push({ t: 'num', value })
toks.push({ t: 'num', value, raw: text })
continue
}
if (/[a-z_]/i.test(c)) {
@@ -171,6 +195,9 @@ function tokenize(src: string): Token[] {
interface Parser {
toks: Token[]
pos: number
/* when set, a name that is not x, y, a constant or a called function
becomes a variable recorded here; when absent, it is refused */
free: Set<string> | null
}
const peek = (p: Parser): Token | null => p.toks[p.pos] ?? null
@@ -238,7 +265,7 @@ function parseAtom(p: Parser): Node {
if (!tk) throw new PlotParseError('syntax')
if (tk.t === 'num') {
p.pos++
return { kind: 'num', value: tk.value }
return { kind: 'num', value: tk.value, raw: tk.raw }
}
if (tk.t === '(') {
p.pos++
@@ -268,28 +295,39 @@ function parseAtom(p: Parser): Node {
if (!arityOk(tk.name, args.length)) throw new PlotParseError('syntax')
return { kind: 'call', name: tk.name, args }
}
// no coordinate, no constant, no name the calculator calls — then it is
// a variable: a slider the reader sets, shared wherever its name is spoken
if (p.free) {
p.free.add(tk.name)
return { kind: 'var', name: tk.name }
}
throw new PlotParseError('unknown')
}
throw new PlotParseError('syntax')
}
function evaluate(node: Node, x: number, y: number): number {
function evaluate(node: Node, x: number, y: number, scope: VarScope): number {
switch (node.kind) {
case 'num':
return node.value
case 'var':
return node.name === 'x' ? x : y
case 'var': {
if (node.name === 'x') return x
if (node.name === 'y') return y
// a slider's set value; until it carries one, where every slider starts
const v = scope[node.name]
return typeof v === 'number' && Number.isFinite(v) ? v : 1
}
case 'const':
return node.value
case 'neg':
return -evaluate(node.arg, x, y)
return -evaluate(node.arg, x, y, scope)
case 'call': {
const fn = FUNCTIONS[node.name]!
return fn(...node.args.map((a) => evaluate(a, x, y)))
return fn(...node.args.map((a) => evaluate(a, x, y, scope)))
}
case 'bin': {
const a = evaluate(node.a, x, y)
const b = evaluate(node.b, x, y)
const a = evaluate(node.a, x, y, scope)
const b = evaluate(node.b, x, y, scope)
switch (node.op) {
case '+':
return a + b
@@ -326,9 +364,26 @@ function mentionsY(node: Node): boolean {
}
}
/* a point's coordinates hold no variable — a lone x or y has no single value
to place a dot at, so only constant expressions (numbers, pi, sqrt(2)…)
make a coordinate */
/* whether x or y stands anywhere in a tree. A point's coordinate may ride
the sliders — each has one value to place the dot at — but a plane
variable has no single value, so the dot would have nowhere to go */
function mentionsPlane(node: Node): boolean {
switch (node.kind) {
case 'var':
return node.name === 'x' || node.name === 'y'
case 'neg':
return mentionsPlane(node.arg)
case 'bin':
return mentionsPlane(node.a) || mentionsPlane(node.b)
case 'call':
return node.args.some(mentionsPlane)
default:
return false
}
}
/* a declaration's value is a plain number — numbers, pi, sqrt(2)… welcome,
sliders and x and y not */
function isConstant(node: Node): boolean {
switch (node.kind) {
case 'var':
@@ -377,62 +432,317 @@ function pointPair(toks: Token[]): [Token[], Token[]] | null {
}
/* one run of tokens, the whole of it — a leftover is a shape the grammar
does not know */
function parseAll(toks: Token[]): Node {
const p: Parser = { toks, pos: 0 }
does not know. Free variables are collected into `free` when given */
function parseAll(toks: Token[], free: Set<string> | null = null): Node {
const p: Parser = { toks, pos: 0, free }
const ast = parseExpr(p)
if (p.pos !== toks.length) throw new PlotParseError('syntax')
return ast
}
/* read one expression, equation, or point; a refusal says whether a name was
not known or the shape was wrong — an empty text is the caller's to treat as
"nothing asked for", not as an error. A "(x, y)" — or "P = (x, y)" with a
name out front — is a point at fixed coordinates. With an "=" otherwise the
text is the relation left − right = 0 (a lone "y =" ahead of an expression
in x is still the fast function); without it, an expression in x is y = f(x)
and one carrying y is the relation that expression equals zero. */
export function compilePlotExpression(src: string): CompiledPlot {
/* a name the reader may claim for a slider: not one of the plane's two
coordinates, not one of the constants, not a function the calculator calls */
function declName(name: string): string | null {
if (name === 'x' || name === 'y') return null
if (Object.hasOwn(CONSTANTS, name) || Object.hasOwn(FUNCTIONS, name)) return null
return name
}
/* read a slider's claim from a token run — "k" alone, or "k = 2" with a
claimable name ahead of a plain number — or null when the text claims
none. The value reads strictly: a "P = (2, 3)" or "k = x" is a point and
a relation, not declarations, and says so by not parsing as a number. */
function declarationOf(toks: Token[]): { name: string; value: number | null } | null {
if (toks.length === 0 || toks[0]!.t !== 'id') return null
const name = declName(toks[0]!.name)
if (!name) return null
if (toks.length === 1) return { name, value: null }
if (toks.length > 2 && toks[1]!.t === '=' && !toks.slice(2).some((tk) => tk.t === '=')) {
try {
const rhs = parseAll(toks.slice(2)) // strict: a declaration's value is a plain number
if (isConstant(rhs)) return { name, value: evaluate(rhs, 0, 0, {}) }
} catch {
/* not a plain number — not a declaration, whatever it reads as instead */
}
}
return null
}
/* read one expression, equation, point or slider declaration; a refusal says
whether a name was not known or the shape was wrong — an empty text is the
caller's to treat as "nothing asked for", not as an error. A "(x, y)" — or
"P = (x, y)" with a name out front — is a point at fixed coordinates. With
an "=" otherwise the text is the relation left − right = 0 (a lone "y ="
ahead of an expression in x is still the fast function); without it, an
expression in x is y = f(x) and one carrying y is the relation that
expression equals zero. A name none of those readings speaks — "k" alone,
or "k = 2" ahead of a plain number — declares a slider: it plots nothing
and its value answers everywhere the name is spoken. Functions, relations
and points list the variables they speak in `vars` and read their values
from `scope` at their every evaluation — so one dragged slider moves the
whole family of curves at once. */
export function compilePlotExpression(src: string, scope: VarScope = {}): 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 at = toks.findIndex((tk) => tk.t === '=')
const free = new Set<string>()
// A slider claiming its own row: "k", or "k = 2" ahead of a plain number
const decl = declarationOf(toks)
if (decl) return { ok: true, kind: 'variable', vars: [decl.name], name: decl.name, value: decl.value }
// A point: "(x, y)" on its own, or "P = (x, y)" with a lone name ahead of
// the "=". Coordinates must be constant — a variable has no single value
// to place a dot at — and finite once read.
// the "=". Coordinates may ride the sliders — and must be finite once read.
const named = at === 1 && toks[0]!.t === 'id'
const pair = pointPair(named ? toks.slice(2) : toks)
if (pair) {
const cx = parseAll(pair[0])
const cy = parseAll(pair[1])
if (!isConstant(cx) || !isConstant(cy)) return { ok: false, reason: 'syntax' }
const x = evaluate(cx, 0, 0)
const y = evaluate(cy, 0, 0)
const cx = parseAll(pair[0], free)
const cy = parseAll(pair[1], free)
if (mentionsPlane(cx) || mentionsPlane(cy)) return { ok: false, reason: 'syntax' }
const x = evaluate(cx, 0, 0, scope)
const y = evaluate(cy, 0, 0, scope)
if (!Number.isFinite(x) || !Number.isFinite(y)) return { ok: false, reason: 'syntax' }
const label = named ? src.slice(0, src.indexOf('=')).trim() : null
return { ok: true, kind: 'point', x, y, label: label || null }
return { ok: true, kind: 'point', vars: [...free], x, y, label: label || null }
}
if (at === -1) {
const ast = parseAll(toks)
const ast = parseAll(toks, free)
return mentionsY(ast)
? { ok: true, kind: 'relation', fn: (x, y) => evaluate(ast, x, y) }
: { ok: true, kind: 'function', fn: (x) => evaluate(ast, x, 0) }
? { ok: true, kind: 'relation', vars: [...free], fn: (x, y) => evaluate(ast, x, y, scope) }
: { ok: true, kind: 'function', vars: [...free], fn: (x) => evaluate(ast, x, 0, scope) }
}
const left = toks.slice(0, at)
const right = toks.slice(at + 1)
if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '='))
throw new PlotParseError('syntax')
const rhs = parseAll(right)
const rhs = parseAll(right, free)
// "y = <only x>" is the explicit curve it always was: x alone decides it
if (left.length === 1 && left[0]!.t === 'id' && left[0]!.name === 'y' && !mentionsY(rhs))
return { ok: true, kind: 'function', fn: (x) => evaluate(rhs, x, 0) }
const lhs = parseAll(left)
return { ok: true, kind: 'relation', fn: (x, y) => evaluate(lhs, x, y) - evaluate(rhs, x, y) }
return { ok: true, kind: 'function', vars: [...free], fn: (x) => evaluate(rhs, x, 0, scope) }
const lhs = parseAll(left, free)
return {
ok: true,
kind: 'relation',
vars: [...free],
fn: (x, y) => evaluate(lhs, x, y, scope) - evaluate(rhs, x, y, scope),
}
} catch (err) {
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
return { ok: false, reason: 'syntax' }
}
}
/* the inside of the 3D field's "z = …" — a lone "z =" ahead of an expression
in x and y; the reader may also write "f(x, y) =" or type the height with
no left side at all, which reads the same */
function stripSurfaceAssignment(src: string): string {
return src
.replace(/^\s*f\s*\(\s*x\s*,\s*y\s*\)\s*=/i, '')
.replace(/^\s*z\s*=/i, '')
}
export type CompiledSurface =
// each carries the free variables it speaks, in order of appearance
| { ok: true; kind: 'surface'; vars: string[]; fn: SurfaceFunction }
// "k" or "k = 2": claims a slider, as in the plane's fields. A null value
// says the text asked for one without saying where it starts. Draws nothing
| { ok: true; kind: 'variable'; vars: string[]; name: string; value: number | null }
| { ok: false; reason: PlotErrorReason }
/* compile the height of a surface above (x, y): the same algebra as the
plane, only read as z = f(x, y). A "z =" ahead of the expression is the
height's own name and sheds; what is left must speak x and y (constants
are welcome — a level surface is still a surface). Names x and y are not
are slider variables read from scope at each evaluation, and "k" or
"k = 2" is the plane's slider declaration — space and plane wear the very
same variables. A stray "=" is still a leftover: space answers a height
alone, and refuses whatever cannot read as one. */
export function compileSurfaceExpression(src: string, scope: VarScope = {}): CompiledSurface {
if (src.length > 400) return { ok: false, reason: 'syntax' }
try {
const body = stripSurfaceAssignment(src)
const toks = tokenize(body)
if (toks.length === 0) return { ok: false, reason: 'empty' }
// a slider claiming its own row: "k", or "k = 2" ahead of a plain number
const decl = declarationOf(toks)
if (decl) return { ok: true, kind: 'variable', vars: [decl.name], name: decl.name, value: decl.value }
const free = new Set<string>()
const ast = parseAll(toks, free)
return { ok: true, kind: 'surface', vars: [...free], fn: (x, y) => evaluate(ast, x, y, scope) }
} catch (err) {
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
return { ok: false, reason: 'syntax' }
}
}
/* ---------- printing the same maths back typeset ---------- */
/* the calculator's fields echo their text as set mathematics while it is
typed (Desmos' way). Printing the parser's own tree keeps the two
readings honest: what is displayed is exactly what would be plotted —
so a text that does not compile simply has no type-setting, and an
unfinished draft stays plain text until it reads right. Fractions are
fractions, radicals are radicals, exponents rise; parentheses appear
only where precedence would otherwise lie. */
const LATEX_FUNCTIONS: Record<string, string> = {
sin: '\\sin',
cos: '\\cos',
tan: '\\tan',
asin: '\\arcsin',
acos: '\\arccos',
atan: '\\arctan',
sinh: '\\sinh',
cosh: '\\cosh',
tanh: '\\tanh',
ln: '\\ln',
log: '\\log',
log2: '\\log_{2}',
log10: '\\log_{10}',
min: '\\min',
max: '\\max',
}
/* a folded constant answers as its symbol when its value is exactly one */
function constSymbol(v: number): string | null {
if (v === Math.PI) return '\\pi'
if (v === Math.PI * 2) return '\\tau'
if (v === Math.E) return 'e'
if (v === (1 + Math.sqrt(5)) / 2) return '\\varphi'
return null
}
/* how tightly a printed node binds: one that binds looser than the context
it stands in gains parentheses */
function nodePrec(n: Node): number {
if (n.kind === 'bin') {
if (n.op === '+' || n.op === '-') return 10
if (n.op === '^') return 40
if (n.op === '/') return 90 // a \\frac carries its own grouping
return 20 // * and %
}
if (n.kind === 'neg') return 30
return 100
}
function printNode(n: Node, ctx: number): string {
const s = printBare(n)
return nodePrec(n) < ctx ? `\\left(${s}\\right)` : s
}
/* two factors standing side by side are multiplied silently — unless the
right one is a number (numbers must not run together), or a bare number
is followed by a fraction (which would read as a mixed number) */
function needsDot(a: Node, b: Node): boolean {
if (b.kind === 'num') return true
return a.kind === 'num' && b.kind === 'bin' && b.op === '/'
}
function printBare(n: Node): string {
switch (n.kind) {
case 'num':
return n.raw ?? String(n.value)
case 'var':
return n.name
case 'const':
return constSymbol(n.value) ?? String(n.value)
case 'neg':
return `-${printNode(n.arg, 30)}`
case 'call':
return printCall(n.name, n.args)
case 'bin': {
switch (n.op) {
case '+':
return `${printNode(n.a, 10)} + ${printNode(n.b, 11)}`
case '-':
return `${printNode(n.a, 10)} - ${printNode(n.b, 11)}`
case '*': {
const a = printNode(n.a, 20)
// a looser factor beside a silent product would change its sense —
// even a leading minus reads as subtraction there. The gap between
// factors is a space: free in math mode, and it keeps a \\pi from
// running into the next atom as one control word
const b = printNode(n.b, 31)
return needsDot(n.a, n.b) ? `${a} \\cdot ${b}` : `${a} ${b}`
}
case '/':
return `\\frac{${printNode(n.a, 0)}}{${printNode(n.b, 0)}}`
case '%':
return `${printNode(n.a, 20)} \\bmod ${printNode(n.b, 21)}`
case '^':
// braces group the exponent outright, so it never needs parentheses;
// an exp(...) base is already a superscribed e — it takes parens
// rather than carry a second superscript
return `${n.a.kind === 'call' && n.a.name === 'exp' ? `\\left(${printBare(n.a)}\\right)` : printNode(n.a, 41)}^{${printNode(n.b, 0)}}`
}
}
}
}
function printCall(name: string, args: Node[]): string {
const one = args[0] ?? { kind: 'num', value: 0 }
switch (name) {
case 'sqrt':
return `\\sqrt{${printNode(one, 0)}}`
case 'cbrt':
return `\\sqrt[3]{${printNode(one, 0)}}`
case 'abs':
return `\\left|${printNode(one, 0)}\\right|`
case 'floor':
return `\\left\\lfloor ${printNode(one, 0)} \\right\\rfloor`
case 'ceil':
return `\\left\\lceil ${printNode(one, 0)} \\right\\rceil`
case 'exp':
return `e^{${printNode(one, 0)}}`
case 'mod':
return `${printNode(args[0] ?? one, 20)} \\bmod ${printNode(args[1] ?? one, 21)}`
default: {
const label = LATEX_FUNCTIONS[name] ?? name
return `${label}\\left(${args.map((a) => printNode(a, 0)).join(',\\, ')}\\right)`
}
}
}
/* the whole text of a field as one typeset line: the same readings the
compiler runs — an "f(x) =" or "z =" ahead of the maths is shed, a lone
"y =" the field's gutter already wears is shed too (the gutter prints
it), a "(x, y)" — optionally named — is a point, a claimable name alone
or ahead of a plain number claims a slider, and any other "=" is the
equation it states. Free variables print as themselves, italic like the
rest. Null when the text does not read: the caller keeps the plain text,
and its red, until it does. */
export function expressionToLatex(src: string): string | null {
if (!src.trim() || src.length > 400) return null
try {
const toks = tokenize(stripSurfaceAssignment(stripAssignment(src)))
if (toks.length === 0) return null
const at = toks.findIndex((tk) => tk.t === '=')
const free = new Set<string>()
const named = at === 1 && toks[0]!.t === 'id'
const pair = pointPair(named ? toks.slice(2) : toks)
if (pair) {
const coords = `\\left(${printNode(parseAll(pair[0], free), 0)},\\, ${printNode(parseAll(pair[1], free), 0)}\\right)`
if (!named) return coords
const label = src.slice(0, src.indexOf('=')).trim()
const name = /^[a-z][a-z0-9_]*$/i.test(label) ? label : `\\text{${label.replace(/[\\{}]/g, '')}}`
return `${name} = ${coords}`
}
if (at === -1) return printNode(parseAll(toks, free), 0)
const left = toks.slice(0, at)
const right = toks.slice(at + 1)
if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '=')) return null
// "y = <only x>" is the function the field's gutter already wears: the
// type-set line answers with its right side alone
if (left.length === 1 && left[0]!.t === 'id' && left[0]!.name === 'y' && !right.some((tk) => tk.t === 'id' && tk.name === 'y'))
return printNode(parseAll(right, free), 0)
// "=" binds loosest of all — neither side ever needs parentheses
return `${printNode(parseAll(left, free), 0)} = ${printNode(parseAll(right, free), 0)}`
} catch {
return null
}
}

View File

@@ -67,17 +67,19 @@ export function isReadingSurface(path: string): boolean {
/* ---------- the wiki's own pages ----------
The link map and the graphing calculator are pages the shelf holds no
copies of — but the book can read them too. Each has one plain address of
its own, and useArticleTabs can lay it on the row beside the open articles:
the page then draws the live surface instead of an article, for as long as
its page stays open. */
The link map, the calculators and the Lean editor are pages the shelf
holds no copies of — but the book can read them too. Each has one plain
address of its own, and useArticleTabs can lay it on the row beside the
open articles: the page then draws the live surface instead of an
article, for as long as its page stays open. */
export type ToolPage = 'graph' | 'calculator'
export type ToolPage = 'graph' | 'calculator' | 'calculator3d' | 'lean'
const TOOL_PAGE_PATHS: Record<ToolPage, string> = {
graph: '/graph',
calculator: '/calculator',
calculator3d: '/calculator3d',
lean: '/lean',
}
/** The address a tool page lives at. */