This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
271
frontend/app/assets/css/_lean.scss
Normal file
271
frontend/app/assets/css/_lean.scss
Normal 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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
354
frontend/app/components/Calculator3DStage.vue
Normal file
354
frontend/app/components/Calculator3DStage.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
424
frontend/app/components/LeanWorkbench.vue
Normal file
424
frontend/app/components/LeanWorkbench.vue
Normal 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>
|
||||
832
frontend/app/components/Scene3D.vue
Normal file
832
frontend/app/components/Scene3D.vue
Normal 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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
60
frontend/app/components/VarSliders.vue
Normal file
60
frontend/app/components/VarSliders.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
287
frontend/app/composables/useLeanSession.ts
Normal file
287
frontend/app/composables/useLeanSession.ts
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
119
frontend/app/composables/useVarSliders.ts
Normal file
119
frontend/app/composables/useVarSliders.ts
Normal 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 }
|
||||
}
|
||||
24
frontend/app/pages/calculator3d.vue
Normal file
24
frontend/app/pages/calculator3d.vue
Normal 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>
|
||||
25
frontend/app/pages/lean.vue
Normal file
25
frontend/app/pages/lean.vue
Normal 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>
|
||||
4
frontend/app/utils/lean/abbreviations.ts
Normal file
4
frontend/app/utils/lean/abbreviations.ts
Normal file
File diff suppressed because one or more lines are too long
252
frontend/app/utils/lean/lean-language.ts
Normal file
252
frontend/app/utils/lean/lean-language.ts
Normal 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())
|
||||
}
|
||||
116
frontend/app/utils/lean/lsp.ts
Normal file
116
frontend/app/utils/lean/lsp.ts
Normal 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 }))
|
||||
}
|
||||
}
|
||||
8
frontend/app/utils/lean/monaco-worker.ts
Normal file
8
frontend/app/utils/lean/monaco-worker.ts
Normal 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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user