Compare commits

..

2 Commits

Author SHA1 Message Date
Julien Calixte
528fc97c7f refactor(model): polish the coffee and limits-to-growth samples
Rename the coffee outflow "heat loss" to "cooling" so the balancing loop
reads more naturally, and stack carrying capacity above the source in
limits to growth so its link to crowding is a clean horizontal hop.
2026-06-20 11:51:49 +02:00
Julien Calixte
21a459256a feat(editor): dock flow arrows square at any pipe angle
Swap the filled-triangle arrowhead for an open arrow on flow pipes and
information links, and route flow pipes through a custom cubic edge whose
horizontal flatten arm scales with the vertical drop, not just the
horizontal gap. Vue Flow's default arm collapses toward zero when the
valve sits nearly above the stock, so a steep pipe stayed flat only in
its last pixels and the arrowhead detached from the visible line. The
pipe now keeps a real horizontal run into the stock's left edge at any
approach angle.
2026-06-20 11:51:34 +02:00
4 changed files with 53 additions and 17 deletions

View File

@@ -36,6 +36,7 @@ import GlossPanel from "./GlossPanel.vue"
import LoopOverlay from "./LoopOverlay.vue"
import Palette from "./Palette.vue"
import InfoLinkEdge from "./edges/InfoLinkEdge.vue"
import PipeEdge from "./edges/PipeEdge.vue"
import CloudNode from "./nodes/CloudNode.vue"
import ConverterNode from "./nodes/ConverterNode.vue"
import FlowNode from "./nodes/FlowNode.vue"
@@ -405,6 +406,9 @@ onBeforeUnmount(() => {
<CloudNode v-bind="nodeProps" />
</template>
<template #edge-pipe="edgeProps">
<PipeEdge v-bind="edgeProps" />
</template>
<template #edge-info="edgeProps">
<InfoLinkEdge v-bind="edgeProps" />
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
/**
* Flow pipe edge (C3) — the source→valve and valve→target segments of a Flow.
*
* The pipe leaves the source on the right and docks square into the target's
* left edge (horizontal tangents both ends), so the arrowhead always enters the
* stock straight-on. Vue Flow's default bezier does the same — but it sizes the
* horizontal "flatten" arm from the *horizontal* gap alone, so when the valve
* sits nearly above the stock that arm collapses to ~zero: the curve stays steep
* and snaps flat only in its final pixels, leaving the arrowhead on a stub of
* horizontal line while the visible stroke arrives steep — the head detaches.
*
* Sizing the arm from the vertical drop too keeps a real horizontal run before
* the stock at any approach angle, so the arrowhead sits on the line and still
* docks square. BaseEdge draws the stroke (same .vue-flow__edge-path class, so
* theme stroke/width are unchanged) and carries the markerEnd through.
*/
import { BaseEdge, type EdgeProps } from "@vue-flow/core"
import { computed } from "vue"
import type { EdgeData } from "@/model/projection"
const props = defineProps<EdgeProps<EdgeData>>()
const path = computed(() => {
const { sourceX: sx, sourceY: sy, targetX: tx, targetY: ty } = props
const arm = Math.min(120, Math.max(30, Math.abs(tx - sx) * 0.5, Math.abs(ty - sy) * 0.45))
// Cubic with horizontal control points: out the source's right, into the
// target's left. `arm` is the run over which each end stays horizontal.
return `M ${sx},${sy} C ${sx + arm},${sy} ${tx - arm},${ty} ${tx},${ty}`
})
</script>
<template>
<BaseEdge :id="props.id" :path="path" :marker-end="props.markerEnd" :style="props.style" />
</template>

View File

@@ -54,6 +54,7 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
// Pipe in: source → valve. No arrowhead — the valve is the visual midpoint.
edges.push({
id: `${node.id}::in`,
type: "pipe",
source: node.source,
sourceHandle: HANDLE_OUT,
target: node.id,
@@ -64,13 +65,14 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
// Pipe out: valve → target. Arrowhead carries the flow direction.
edges.push({
id: `${node.id}::out`,
type: "pipe",
source: node.id,
sourceHandle: HANDLE_OUT,
target: node.target,
targetHandle: HANDLE_IN,
data: { kind: "pipe" },
style: { strokeWidth: "2.5px" },
markerEnd: "arrowclosed",
markerEnd: "arrow",
})
}
@@ -86,7 +88,7 @@ export function projectEdges(model: Model): FlowGraphEdge[] {
targetHandle: HANDLE_IN,
data: { kind: "info", polarity: link.polarity },
style: { strokeDasharray: "5 4" },
markerEnd: "arrowclosed",
markerEnd: "arrow",
})
}

View File

@@ -102,25 +102,20 @@ function savings(): Model {
}
/**
* Coffee cooling — the simplest Balancing loop, plus a Converter. Heat leaves the
* cup faster the hotter it is (Coffee → [+] → heat loss) but slower the warmer
* the room (room temperature → [] → heat loss). The loop Coffee → heat loss
* (outflow) → Coffee has one `` → Balancing: it settles toward room temperature.
* Coffee cooling — the simplest Balancing loop, plus a Converter. The cup cools
* faster the hotter it is (Coffee → [+] → cooling) but slower the warmer the room
* (room temperature → [] → cooling). The loop Coffee → cooling → (outflow)
* Coffee has one `` → Balancing: it settles toward room temperature.
*/
function coffee(): Model {
const coffee = makeStock({ x: -200, y: 0 }, "Coffee")
const sink = makeCloud({ x: 200, y: 0 })
const heatLoss = makeFlow(
midpoint(coffee.position, sink.position),
"heat loss",
coffee.id,
sink.id,
)
const cooling = makeFlow(midpoint(coffee.position, sink.position), "cooling", coffee.id, sink.id)
const room = makeConverter({ x: 0, y: -160 }, "room temperature")
return model(
"Coffee cooling",
[coffee, sink, heatLoss, room],
[link(coffee, heatLoss, "+"), link(room, heatLoss, "-")],
[coffee, sink, cooling, room],
[link(coffee, cooling, "+"), link(room, cooling, "-")],
)
}
@@ -166,10 +161,10 @@ function limitsToGrowth(): Model {
const source = makeCloud({ x: -280, y: 0 })
const yeast = makeStock({ x: 40, y: 0 }, "Yeast")
const growth = makeFlow(midpoint(source.position, yeast.position), "growth", source.id, yeast.id)
// crowding rides above the pipe; carrying capacity sits to its right so the
// `capacity → crowding` link is a short horizontal hop along the top.
// crowding rides above the pipe; carrying capacity stacks above the Source on the
// left, so the `capacity → crowding` link is a clean horizontal hop along the top.
const capacity = makeConverter({ x: -280, y: -160 }, "carrying capacity")
const crowding = makeConverter({ x: -40, y: -160 }, "crowding")
const capacity = makeConverter({ x: 160, y: -160 }, "carrying capacity")
return model(
"Limits to growth",
[source, yeast, growth, crowding, capacity],