Compare commits
4 Commits
acce53ce47
...
cd276d9d2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd276d9d2a | ||
|
|
e1efa3c823 | ||
|
|
6815b81701 | ||
|
|
5e50b2dfd4 |
@@ -25,7 +25,7 @@ import {
|
|||||||
VueFlow,
|
VueFlow,
|
||||||
type XYPosition,
|
type XYPosition,
|
||||||
} from "@vue-flow/core"
|
} from "@vue-flow/core"
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, useTemplateRef } from "vue"
|
import { computed, onBeforeUnmount, onMounted, useTemplateRef } from "vue"
|
||||||
import { useAutosave } from "@/composables/useAutosave"
|
import { useAutosave } from "@/composables/useAutosave"
|
||||||
import { parseModel, serializeModel } from "@/model/io"
|
import { parseModel, serializeModel } from "@/model/io"
|
||||||
import { project } from "@/model/projection"
|
import { project } from "@/model/projection"
|
||||||
@@ -33,6 +33,7 @@ import { type Sample, SAMPLES } from "@/model/samples"
|
|||||||
import { canConnect } from "@/model/validation"
|
import { canConnect } from "@/model/validation"
|
||||||
import { useModelStore } from "@/store/model"
|
import { useModelStore } from "@/store/model"
|
||||||
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
||||||
|
import GlossPanel from "./GlossPanel.vue"
|
||||||
import LoopOverlay from "./LoopOverlay.vue"
|
import LoopOverlay from "./LoopOverlay.vue"
|
||||||
import Palette from "./Palette.vue"
|
import Palette from "./Palette.vue"
|
||||||
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
|
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
|
||||||
@@ -57,6 +58,7 @@ const {
|
|||||||
onConnectStart,
|
onConnectStart,
|
||||||
onConnectEnd,
|
onConnectEnd,
|
||||||
onError,
|
onError,
|
||||||
|
onNodesInitialized,
|
||||||
getSelectedNodes,
|
getSelectedNodes,
|
||||||
getSelectedEdges,
|
getSelectedEdges,
|
||||||
viewport,
|
viewport,
|
||||||
@@ -68,6 +70,18 @@ const {
|
|||||||
// view once a restored model has re-projected so it lands framed.
|
// view once a restored model has re-projected so it lands framed.
|
||||||
useAutosave({ onRestore: () => fitView({ padding: 0.2 }) })
|
useAutosave({ onRestore: () => fitView({ padding: 0.2 }) })
|
||||||
|
|
||||||
|
// Fit the view after a *load* (sample/import), once the freshly projected nodes
|
||||||
|
// have been measured. Vue Flow measures node dimensions a frame after they mount,
|
||||||
|
// so calling fitView straight after setModel fits to 0×0 boxes and the model
|
||||||
|
// lands jammed in the top-left; onNodesInitialized fires post-measure, so framing
|
||||||
|
// is correct. A one-shot flag keeps it scoped to loads (not every re-init).
|
||||||
|
let fitAfterInit = false
|
||||||
|
onNodesInitialized(() => {
|
||||||
|
if (!fitAfterInit) return
|
||||||
|
fitAfterInit = false
|
||||||
|
fitView({ padding: 0.2 })
|
||||||
|
})
|
||||||
|
|
||||||
onNodeDragStart(() => store.beginInteraction())
|
onNodeDragStart(() => store.beginInteraction())
|
||||||
onNodeDragStop(({ nodes: dragged }) => {
|
onNodeDragStop(({ nodes: dragged }) => {
|
||||||
for (const node of dragged) store.moveNode(node.id, node.position)
|
for (const node of dragged) store.moveNode(node.id, node.position)
|
||||||
@@ -175,15 +189,14 @@ function onDragOver(event: DragEvent): void {
|
|||||||
* stay frictionless on the empty starting canvas. Fit the view once the
|
* stay frictionless on the empty starting canvas. Fit the view once the
|
||||||
* projection has re-derived so the loaded model lands framed.
|
* projection has re-derived so the loaded model lands framed.
|
||||||
*/
|
*/
|
||||||
async function loadSample(sample: Sample): Promise<void> {
|
function loadSample(sample: Sample): void {
|
||||||
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
|
if (store.nodeCount > 0 && !window.confirm(`Replace the current model with “${sample.title}”?`)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
fitAfterInit = true
|
||||||
store.setModel(sample.build())
|
store.setModel(sample.build())
|
||||||
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus).
|
// Close the DaisyUI dropdown (it stays open while the trigger keeps focus).
|
||||||
;(document.activeElement as HTMLElement | null)?.blur()
|
;(document.activeElement as HTMLElement | null)?.blur()
|
||||||
await nextTick()
|
|
||||||
fitView({ padding: 0.2 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
|
const fileInput = useTemplateRef<HTMLInputElement>("fileInput")
|
||||||
@@ -229,9 +242,8 @@ async function onImportFile(event: Event): Promise<void> {
|
|||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
fitAfterInit = true
|
||||||
store.setModel(result.model)
|
store.setModel(result.model)
|
||||||
await nextTick()
|
|
||||||
fitView({ padding: 0.2 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTextEntry(target: EventTarget | null): boolean {
|
function isTextEntry(target: EventTarget | null): boolean {
|
||||||
@@ -352,6 +364,7 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
|
|||||||
</VueFlow>
|
</VueFlow>
|
||||||
|
|
||||||
<LoopOverlay />
|
<LoopOverlay />
|
||||||
|
<GlossPanel />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
45
src/components/GlossPanel.vue
Normal file
45
src/components/GlossPanel.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Gloss-on-selection (F11, G4) — when exactly one element is selected, name it
|
||||||
|
* and define it in a small corner card. Selection is deliberate and singular, so
|
||||||
|
* the canvas stays pixel-clean and silent until you ask — no passive hover noise
|
||||||
|
* on a dense model. Self-contained: reads selection straight from the shared Vue
|
||||||
|
* Flow instance, so it drops into the Editor as one tag.
|
||||||
|
*
|
||||||
|
* A pipe edge stands in for its Flow node, so selecting one glosses the Flow; an
|
||||||
|
* info edge glosses the Information Link. Anything else (nothing, or a multi-
|
||||||
|
* selection) hides the card.
|
||||||
|
*/
|
||||||
|
import { useVueFlow } from "@vue-flow/core"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { type GlossEntry, INFO_LINK_GLOSS, NODE_GLOSSARY } from "@/model/glossary"
|
||||||
|
import type { EdgeData, NodeData } from "@/model/projection"
|
||||||
|
|
||||||
|
const { getSelectedNodes, getSelectedEdges } = useVueFlow("meadows")
|
||||||
|
|
||||||
|
const gloss = computed<GlossEntry | null>(() => {
|
||||||
|
const nodes = getSelectedNodes.value
|
||||||
|
const edges = getSelectedEdges.value
|
||||||
|
|
||||||
|
if (nodes.length === 1 && edges.length === 0) {
|
||||||
|
const node = (nodes[0].data as NodeData | undefined)?.node
|
||||||
|
return node ? NODE_GLOSSARY[node.kind] : null
|
||||||
|
}
|
||||||
|
if (edges.length === 1 && nodes.length === 0) {
|
||||||
|
const kind = (edges[0].data as EdgeData | undefined)?.kind
|
||||||
|
if (kind === "info") return INFO_LINK_GLOSS
|
||||||
|
if (kind === "pipe") return NODE_GLOSSARY.flow
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="gloss"
|
||||||
|
class="pointer-events-none absolute right-3 bottom-3 z-10 max-w-xs rounded-box border border-base-300 bg-base-100/90 p-3 shadow-md backdrop-blur"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-semibold">{{ gloss.term }}</div>
|
||||||
|
<p class="mt-0.5 text-xs leading-snug text-base-content/70">{{ gloss.short }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
* dataTransfer on drag). Flows aren't placed here — they're drawn by connecting
|
* dataTransfer on drag). Flows aren't placed here — they're drawn by connecting
|
||||||
* two nodes (F2, next).
|
* two nodes (F2, next).
|
||||||
*/
|
*/
|
||||||
|
import { NODE_GLOSSARY } from "@/model/glossary"
|
||||||
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
import { NODE_DND_MIME, type PlaceableKind } from "./palette-dnd"
|
||||||
|
|
||||||
const emit = defineEmits<{ add: [kind: PlaceableKind] }>()
|
const emit = defineEmits<{ add: [kind: PlaceableKind] }>()
|
||||||
@@ -27,8 +28,10 @@ function onDragStart(event: DragEvent, kind: PlaceableKind): void {
|
|||||||
Add
|
Add
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-sm cursor-grab justify-start gap-2 active:cursor-grabbing"
|
class="btn btn-ghost btn-sm tooltip tooltip-right cursor-grab justify-start gap-2 active:cursor-grabbing"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
|
:data-tip="NODE_GLOSSARY.stock.short"
|
||||||
|
:aria-label="`Stock — ${NODE_GLOSSARY.stock.short}`"
|
||||||
@click="emit('add', 'stock')"
|
@click="emit('add', 'stock')"
|
||||||
@dragstart="onDragStart($event, 'stock')"
|
@dragstart="onDragStart($event, 'stock')"
|
||||||
>
|
>
|
||||||
@@ -36,8 +39,10 @@ function onDragStart(event: DragEvent, kind: PlaceableKind): void {
|
|||||||
Stock
|
Stock
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-sm cursor-grab justify-start gap-2 active:cursor-grabbing"
|
class="btn btn-ghost btn-sm tooltip tooltip-right cursor-grab justify-start gap-2 active:cursor-grabbing"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
|
:data-tip="NODE_GLOSSARY.converter.short"
|
||||||
|
:aria-label="`Converter — ${NODE_GLOSSARY.converter.short}`"
|
||||||
@click="emit('add', 'converter')"
|
@click="emit('add', 'converter')"
|
||||||
@dragstart="onDragStart($event, 'converter')"
|
@dragstart="onDragStart($event, 'converter')"
|
||||||
>
|
>
|
||||||
|
|||||||
48
src/model/glossary.ts
Normal file
48
src/model/glossary.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* UI glossary (F11, G4) — one-line definitions of the language, surfaced where a
|
||||||
|
* newcomer meets each term: as palette tooltips before placing, and in the
|
||||||
|
* gloss-on-selection panel after.
|
||||||
|
*
|
||||||
|
* CONTEXT.md is the authority for the vocabulary; these are deliberately terser
|
||||||
|
* (a tooltip can't hold the full entry with its `Avoid:` clauses). Keep them in
|
||||||
|
* sync with CONTEXT.md by hand — there's no test runner yet to assert it; when
|
||||||
|
* one lands, a check that every `NodeKind` has an entry and each `term` matches a
|
||||||
|
* CONTEXT.md heading would close the drift gap.
|
||||||
|
*/
|
||||||
|
import type { NodeKind } from "./types"
|
||||||
|
|
||||||
|
export interface GlossEntry {
|
||||||
|
/** The canonical term, verbatim from CONTEXT.md. */
|
||||||
|
term: string
|
||||||
|
/** A one-sentence gloss, distilled from the CONTEXT.md definition. */
|
||||||
|
short: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NODE_GLOSSARY: Record<NodeKind, GlossEntry> = {
|
||||||
|
stock: {
|
||||||
|
term: "Stock",
|
||||||
|
short:
|
||||||
|
"An accumulation that holds a quantity over time — the system's memory. Only Flows can change it.",
|
||||||
|
},
|
||||||
|
flow: {
|
||||||
|
term: "Flow",
|
||||||
|
short:
|
||||||
|
"A rate that moves quantity into or out of a Stock — the only element that can change one.",
|
||||||
|
},
|
||||||
|
converter: {
|
||||||
|
term: "Converter",
|
||||||
|
short:
|
||||||
|
"A stateless helper value, recomputed each instant from its inputs (or a fixed constant).",
|
||||||
|
},
|
||||||
|
cloud: {
|
||||||
|
term: "Source / Sink",
|
||||||
|
short:
|
||||||
|
"The model boundary on an open Flow end — where quantity comes from (Source) or goes (Sink).",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INFO_LINK_GLOSS: GlossEntry = {
|
||||||
|
term: "Information Link",
|
||||||
|
short:
|
||||||
|
"Carries one element's value to a Flow or Converter — influence only, never quantity; its sign is its Polarity.",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user