Compare commits
2 Commits
9f11d5230d
...
26dd245a94
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26dd245a94 | ||
|
|
169dd043c3 |
@@ -24,7 +24,7 @@ import {
|
|||||||
VueFlow,
|
VueFlow,
|
||||||
type XYPosition,
|
type XYPosition,
|
||||||
} from "@vue-flow/core"
|
} from "@vue-flow/core"
|
||||||
import { computed, onBeforeUnmount, onMounted, useTemplateRef } from "vue"
|
import { computed, onBeforeUnmount, onMounted, ref, 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"
|
||||||
@@ -114,16 +114,51 @@ onError((err) => {
|
|||||||
// so validating projected edges would drop every pipe-out (Vue Flow EDGE_INVALID).
|
// so validating projected edges would drop every pipe-out (Vue Flow EDGE_INVALID).
|
||||||
function isValidConnection(connection: Connection): boolean {
|
function isValidConnection(connection: Connection): boolean {
|
||||||
if ("id" in connection) return true
|
if ("id" in connection) return true
|
||||||
return canConnect(store.model, connection.source, connection.target)
|
const ok = canConnect(store.model, connection.source, connection.target)
|
||||||
|
// Remember the latest *refused* close-the-loop attempt so the release handler
|
||||||
|
// can explain it. Overwrite on every candidate (clearing when valid) so this
|
||||||
|
// reflects the handle we actually let go on, not one we merely passed over.
|
||||||
|
pendingLoopHint = ok ? null : loopClosureHint(connection.source, connection.target)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message for dragging a Flow back onto a Stock it already touches — the
|
||||||
|
* "close the loop by hand" gesture. The drop is refused (an Information Link
|
||||||
|
* never targets a Stock; Stocks change only via Flows, ADR-0001), but the loop
|
||||||
|
* is already there: the Flow's own pipe *is* the link back. Naming that turns a
|
||||||
|
* dead-end gesture into the lesson (F4). Returns null for any other refusal,
|
||||||
|
* which stays silent.
|
||||||
|
*/
|
||||||
|
function loopClosureHint(sourceId: string, targetId: string): string | null {
|
||||||
|
const flow = store.model.nodes.find((node) => node.id === sourceId)
|
||||||
|
if (flow?.kind !== "flow") return null
|
||||||
|
const stock = store.model.nodes.find((node) => node.id === targetId)
|
||||||
|
if (stock?.kind !== "stock") return null
|
||||||
|
if (flow.source !== stock.id && flow.target !== stock.id) return null
|
||||||
|
const direction = flow.source === stock.id ? "out of" : "into"
|
||||||
|
return `${flow.name} already flows ${direction} ${stock.name} — that pipe is the link back, so the loop is already there.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A transient, self-dismissing teaching hint shown over the canvas (F4).
|
||||||
|
const hint = ref<string | null>(null)
|
||||||
|
let hintTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
function showHint(message: string): void {
|
||||||
|
hint.value = message
|
||||||
|
clearTimeout(hintTimer)
|
||||||
|
hintTimer = setTimeout(() => (hint.value = null), 6000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remember the drag's source so releasing on empty canvas can open onto a Cloud.
|
// Remember the drag's source so releasing on empty canvas can open onto a Cloud.
|
||||||
let connectSource: string | null = null
|
let connectSource: string | null = null
|
||||||
let connectionMade = false
|
let connectionMade = false
|
||||||
|
// Set by isValidConnection when the live gesture is a refused loop-closure.
|
||||||
|
let pendingLoopHint: string | null = null
|
||||||
|
|
||||||
onConnectStart((params: OnConnectStartParams) => {
|
onConnectStart((params: OnConnectStartParams) => {
|
||||||
connectSource = params.handleType === "source" ? (params.nodeId ?? null) : null
|
connectSource = params.handleType === "source" ? (params.nodeId ?? null) : null
|
||||||
connectionMade = false
|
connectionMade = false
|
||||||
|
pendingLoopHint = null
|
||||||
})
|
})
|
||||||
|
|
||||||
onConnect((connection) => {
|
onConnect((connection) => {
|
||||||
@@ -132,11 +167,18 @@ onConnect((connection) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onConnectEnd((event) => {
|
onConnectEnd((event) => {
|
||||||
if (!connectionMade && connectSource && event) {
|
if (!connectionMade) {
|
||||||
|
// A refused loop-closure and an empty-canvas open are mutually exclusive
|
||||||
|
// (the former needs a Flow source, the latter a Stock source), so prefer the
|
||||||
|
// explanation when we have one.
|
||||||
|
if (pendingLoopHint) showHint(pendingLoopHint)
|
||||||
|
else if (connectSource && event) {
|
||||||
const point = pointerPosition(event)
|
const point = pointerPosition(event)
|
||||||
if (point) store.connectToNewCloud(connectSource, screenToFlow(point.x, point.y))
|
if (point) store.connectToNewCloud(connectSource, screenToFlow(point.x, point.y))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
connectSource = null
|
connectSource = null
|
||||||
|
pendingLoopHint = null
|
||||||
})
|
})
|
||||||
|
|
||||||
/** clientX/clientY from a mouse or touch end event. */
|
/** clientX/clientY from a mouse or touch end event. */
|
||||||
@@ -286,7 +328,10 @@ function onKeydown(event: KeyboardEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => window.addEventListener("keydown", onKeydown))
|
onMounted(() => window.addEventListener("keydown", onKeydown))
|
||||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener("keydown", onKeydown)
|
||||||
|
clearTimeout(hintTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -369,6 +414,23 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
|
|||||||
<Palette class="absolute top-3 left-3 z-20" @add="addNode" />
|
<Palette class="absolute top-3 left-3 z-20" @add="addNode" />
|
||||||
<LoopOverlay />
|
<LoopOverlay />
|
||||||
<GlossPanel />
|
<GlossPanel />
|
||||||
|
|
||||||
|
<!-- Self-dismissing teaching hint, e.g. when a Flow is dragged back onto its
|
||||||
|
own Stock to "close the loop". Click to dismiss early; z-30 keeps it above
|
||||||
|
the palette and badge overlay. role=status announces it politely. -->
|
||||||
|
<div v-if="hint" class="toast toast-center toast-bottom z-30">
|
||||||
|
<div role="status" class="alert alert-info max-w-sm text-left shadow-lg">
|
||||||
|
<span>{{ hint }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-circle btn-ghost btn-xs"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
@click="hint = null"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,6 +12,23 @@
|
|||||||
* 3. Coffee cooling — + Converter, `−` polarity → a Balancing loop.
|
* 3. Coffee cooling — + Converter, `−` polarity → a Balancing loop.
|
||||||
* 4. Population — all of the above at once: Reinforcing *and* Balancing.
|
* 4. Population — all of the above at once: Reinforcing *and* Balancing.
|
||||||
*
|
*
|
||||||
|
* Beyond that primer, three classic models go a step further — each adds one
|
||||||
|
* structure the first four never show, so they read as a second tier:
|
||||||
|
*
|
||||||
|
* 5. Limits to growth — two loops (R and B) fighting over a single Flow, plus a
|
||||||
|
* constant Converter (carrying capacity) that feeds a loop
|
||||||
|
* without being part of it.
|
||||||
|
* 6. Predator and prey — two coupled Stocks whose interlocking loops oscillate.
|
||||||
|
* 7. Epidemic — a chain of Stocks joined by Stock→Stock Flows: no clouds.
|
||||||
|
*
|
||||||
|
* Last, two of Donella Meadows' system *traps* — structures that reliably misbehave
|
||||||
|
* (Thinking in Systems, ch. 5), to contrast the healthy dynamics above:
|
||||||
|
*
|
||||||
|
* 8. Tragedy of the commons — competing Reinforcing loops drain a shared Stock
|
||||||
|
* faster than its weak, shared Balancing brake reacts.
|
||||||
|
* 9. Escalation — a single Reinforcing loop spanning two Stocks, with
|
||||||
|
* no brake in the structure: an arms race.
|
||||||
|
*
|
||||||
* These are plain data built from the same tested constructors the store uses
|
* These are plain data built from the same tested constructors the store uses
|
||||||
* (factory.ts), so every sample is a valid Model by construction. `build()`
|
* (factory.ts), so every sample is a valid Model by construction. `build()`
|
||||||
* mints fresh ids on each call, so loading a sample twice never collides.
|
* mints fresh ids on each call, so loading a sample twice never collides.
|
||||||
@@ -134,6 +151,232 @@ function population(): Model {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limits to growth — the S-curve, and the first model where two loops fight over
|
||||||
|
* one Flow. Yeast multiplies the more there is of it (Yeast → [+] → growth: a
|
||||||
|
* Reinforcing loop), but the fuller the vat the more crowding holds growth back
|
||||||
|
* (Yeast → [+] → crowding → [−] → growth: a Balancing loop). Carrying capacity is
|
||||||
|
* a *constant* Converter — no inputs — that sets how soon crowding bites; it feeds
|
||||||
|
* the balancing loop without sitting on any cycle.
|
||||||
|
*/
|
||||||
|
function limitsToGrowth(): Model {
|
||||||
|
const source = makeCloud({ x: -300, 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.
|
||||||
|
const crowding = makeConverter({ x: -40, y: -150 }, "crowding")
|
||||||
|
const capacity = makeConverter({ x: 170, y: -150 }, "carrying capacity")
|
||||||
|
return model(
|
||||||
|
"Limits to growth",
|
||||||
|
[source, yeast, growth, crowding, capacity],
|
||||||
|
[
|
||||||
|
link(yeast, growth, "+"),
|
||||||
|
link(yeast, crowding, "+"),
|
||||||
|
link(crowding, growth, "-"),
|
||||||
|
link(capacity, crowding, "-"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predator and prey — the first model with two Stocks, coupled so each drives the
|
||||||
|
* other (Lotka–Volterra). Rabbits breed (Reinforcing) and are thinned by predation
|
||||||
|
* (Balancing); foxes die off on their own (Balancing). The interesting one is the
|
||||||
|
* cross-stock loop Rabbits → fox births → Foxes → predation → Rabbits: more rabbits
|
||||||
|
* feed more foxes, more foxes eat more rabbits — one `−` → Balancing, and the lag
|
||||||
|
* around it is what makes the two populations oscillate.
|
||||||
|
*/
|
||||||
|
function predatorPrey(): Model {
|
||||||
|
// Rabbits run along the top row, foxes along a lower row, so the two long
|
||||||
|
// coupling links cross in the open space between the rows.
|
||||||
|
const preySource = makeCloud({ x: -520, y: 0 })
|
||||||
|
const rabbits = makeStock({ x: -260, y: 0 }, "Rabbits")
|
||||||
|
const preySink = makeCloud({ x: 0, y: 0 })
|
||||||
|
const rabbitBirths = makeFlow(
|
||||||
|
midpoint(preySource.position, rabbits.position),
|
||||||
|
"rabbit births",
|
||||||
|
preySource.id,
|
||||||
|
rabbits.id,
|
||||||
|
)
|
||||||
|
const predation = makeFlow(
|
||||||
|
midpoint(rabbits.position, preySink.position),
|
||||||
|
"predation",
|
||||||
|
rabbits.id,
|
||||||
|
preySink.id,
|
||||||
|
)
|
||||||
|
const foxSource = makeCloud({ x: 120, y: 220 })
|
||||||
|
const foxes = makeStock({ x: 380, y: 220 }, "Foxes")
|
||||||
|
const foxSink = makeCloud({ x: 640, y: 220 })
|
||||||
|
const foxBirths = makeFlow(
|
||||||
|
midpoint(foxSource.position, foxes.position),
|
||||||
|
"fox births",
|
||||||
|
foxSource.id,
|
||||||
|
foxes.id,
|
||||||
|
)
|
||||||
|
const foxDeaths = makeFlow(
|
||||||
|
midpoint(foxes.position, foxSink.position),
|
||||||
|
"fox deaths",
|
||||||
|
foxes.id,
|
||||||
|
foxSink.id,
|
||||||
|
)
|
||||||
|
return model(
|
||||||
|
"Predator and prey",
|
||||||
|
[
|
||||||
|
preySource,
|
||||||
|
rabbits,
|
||||||
|
preySink,
|
||||||
|
rabbitBirths,
|
||||||
|
predation,
|
||||||
|
foxSource,
|
||||||
|
foxes,
|
||||||
|
foxSink,
|
||||||
|
foxBirths,
|
||||||
|
foxDeaths,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
link(rabbits, rabbitBirths, "+"),
|
||||||
|
link(rabbits, predation, "+"),
|
||||||
|
link(foxes, predation, "+"),
|
||||||
|
link(rabbits, foxBirths, "+"),
|
||||||
|
link(foxes, foxDeaths, "+"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Epidemic — contagion as a chain of three Stocks (Susceptible → Infected →
|
||||||
|
* Recovered) with no model boundary: every Flow runs Stock → Stock, so no clouds
|
||||||
|
* appear. Infection feeds on both ends at once (Susceptible → [+] and Infected →
|
||||||
|
* [+] → infection): the more infected there are the faster it spreads — a
|
||||||
|
* Reinforcing outbreak — until susceptibles run low (Balancing) and recovery
|
||||||
|
* drains the infected (Balancing). Infectivity is a constant Converter setting the
|
||||||
|
* pace; Recovered is a terminal Stock, on no loop.
|
||||||
|
*/
|
||||||
|
function epidemic(): Model {
|
||||||
|
const susceptible = makeStock({ x: -280, y: 0 }, "Susceptible")
|
||||||
|
const infected = makeStock({ x: 0, y: 0 }, "Infected")
|
||||||
|
const recovered = makeStock({ x: 280, y: 0 }, "Recovered")
|
||||||
|
const infection = makeFlow(
|
||||||
|
midpoint(susceptible.position, infected.position),
|
||||||
|
"infection",
|
||||||
|
susceptible.id,
|
||||||
|
infected.id,
|
||||||
|
)
|
||||||
|
const recovery = makeFlow(
|
||||||
|
midpoint(infected.position, recovered.position),
|
||||||
|
"recovery",
|
||||||
|
infected.id,
|
||||||
|
recovered.id,
|
||||||
|
)
|
||||||
|
const infectivity = makeConverter({ x: -140, y: -150 }, "infectivity")
|
||||||
|
return model(
|
||||||
|
"Epidemic",
|
||||||
|
[susceptible, infected, recovered, infection, recovery, infectivity],
|
||||||
|
[
|
||||||
|
link(susceptible, infection, "+"),
|
||||||
|
link(infected, infection, "+"),
|
||||||
|
link(infected, recovery, "+"),
|
||||||
|
link(infectivity, infection, "+"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tragedy of the commons — Meadows' first system *trap*: several users sharing one
|
||||||
|
* resource, each with a Reinforcing loop that grows its own use, and only a weak,
|
||||||
|
* shared Balancing loop to rein them in. Each Herd breeds the more cattle it has
|
||||||
|
* (Herd → [+] → growth: Reinforcing) and grazes the shared Pasture (Herd → [+] →
|
||||||
|
* grazing, which drains Pasture). Less Pasture does slow each herd (Pasture → [+] →
|
||||||
|
* growth: a Balancing loop per herd) — but that brake runs through the *one* shared
|
||||||
|
* Stock, so in practice it is too slow to stop the herds racing each other down to
|
||||||
|
* bare dirt. The trap is structural: each herder gains by growing, while the cost
|
||||||
|
* falls on the commons they both depend on.
|
||||||
|
*/
|
||||||
|
function tragedyOfTheCommons(): Model {
|
||||||
|
const pasture = makeStock({ x: 0, y: 0 }, "Pasture")
|
||||||
|
// Two symmetric herds: cattle enter from a Source on the outside, grass leaves
|
||||||
|
// the Pasture downward to a Sink. The two `Pasture → growth` links are the weak
|
||||||
|
// brake the trap overruns.
|
||||||
|
const sourceA = makeCloud({ x: -620, y: 0 })
|
||||||
|
const herdA = makeStock({ x: -360, y: 0 }, "Herd A")
|
||||||
|
const growthA = makeFlow(
|
||||||
|
midpoint(sourceA.position, herdA.position),
|
||||||
|
"growth A",
|
||||||
|
sourceA.id,
|
||||||
|
herdA.id,
|
||||||
|
)
|
||||||
|
const sinkA = makeCloud({ x: -180, y: 220 })
|
||||||
|
const grazingA = makeFlow(
|
||||||
|
midpoint(pasture.position, sinkA.position),
|
||||||
|
"grazing A",
|
||||||
|
pasture.id,
|
||||||
|
sinkA.id,
|
||||||
|
)
|
||||||
|
const sourceB = makeCloud({ x: 620, y: 0 })
|
||||||
|
const herdB = makeStock({ x: 360, y: 0 }, "Herd B")
|
||||||
|
const growthB = makeFlow(
|
||||||
|
midpoint(sourceB.position, herdB.position),
|
||||||
|
"growth B",
|
||||||
|
sourceB.id,
|
||||||
|
herdB.id,
|
||||||
|
)
|
||||||
|
const sinkB = makeCloud({ x: 180, y: 220 })
|
||||||
|
const grazingB = makeFlow(
|
||||||
|
midpoint(pasture.position, sinkB.position),
|
||||||
|
"grazing B",
|
||||||
|
pasture.id,
|
||||||
|
sinkB.id,
|
||||||
|
)
|
||||||
|
return model(
|
||||||
|
"Tragedy of the commons",
|
||||||
|
[pasture, sourceA, herdA, growthA, sinkA, grazingA, sourceB, herdB, growthB, sinkB, grazingB],
|
||||||
|
[
|
||||||
|
link(herdA, growthA, "+"),
|
||||||
|
link(herdA, grazingA, "+"),
|
||||||
|
link(pasture, growthA, "+"),
|
||||||
|
link(herdB, growthB, "+"),
|
||||||
|
link(herdB, grazingB, "+"),
|
||||||
|
link(pasture, growthB, "+"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escalation — the arms-race trap: two Stocks locked in a single Reinforcing loop,
|
||||||
|
* each building up in answer to the other. The more the Blue arsenal holds the
|
||||||
|
* faster Red builds (Blue → [+] → Red buildup), and vice versa, so the loop
|
||||||
|
* Red arsenal → Blue buildup → Blue arsenal → Red buildup → Red arsenal carries no
|
||||||
|
* `−` → Reinforcing: with no brake in the structure, both grow without bound. (The
|
||||||
|
* benign cousin is "Predator and prey", whose cross loop has one `−` and so settles
|
||||||
|
* into oscillation instead of exploding.)
|
||||||
|
*/
|
||||||
|
function escalation(): Model {
|
||||||
|
// Both arsenals sit inboard with their Sources outside; the two links crossing
|
||||||
|
// the centre are the escalation loop.
|
||||||
|
const redSource = makeCloud({ x: -520, y: 0 })
|
||||||
|
const redArsenal = makeStock({ x: -260, y: 0 }, "Red arsenal")
|
||||||
|
const redBuildup = makeFlow(
|
||||||
|
midpoint(redSource.position, redArsenal.position),
|
||||||
|
"Red buildup",
|
||||||
|
redSource.id,
|
||||||
|
redArsenal.id,
|
||||||
|
)
|
||||||
|
const blueSource = makeCloud({ x: 520, y: 0 })
|
||||||
|
const blueArsenal = makeStock({ x: 260, y: 0 }, "Blue arsenal")
|
||||||
|
const blueBuildup = makeFlow(
|
||||||
|
midpoint(blueSource.position, blueArsenal.position),
|
||||||
|
"Blue buildup",
|
||||||
|
blueSource.id,
|
||||||
|
blueArsenal.id,
|
||||||
|
)
|
||||||
|
return model(
|
||||||
|
"Escalation",
|
||||||
|
[redSource, redArsenal, redBuildup, blueSource, blueArsenal, blueBuildup],
|
||||||
|
[link(blueArsenal, redBuildup, "+"), link(redArsenal, blueBuildup, "+")],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** The gallery, ordered simplest first. */
|
/** The gallery, ordered simplest first. */
|
||||||
export const SAMPLES: Sample[] = [
|
export const SAMPLES: Sample[] = [
|
||||||
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub },
|
{ title: "Bathtub", blurb: "A stock filled and drained — no feedback yet.", build: bathtub },
|
||||||
@@ -152,4 +395,29 @@ export const SAMPLES: Sample[] = [
|
|||||||
blurb: "Births and deaths: Reinforcing and Balancing together.",
|
blurb: "Births and deaths: Reinforcing and Balancing together.",
|
||||||
build: population,
|
build: population,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Limits to growth",
|
||||||
|
blurb: "Growth into a ceiling: a Reinforcing and a Balancing loop on one Flow.",
|
||||||
|
build: limitsToGrowth,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Predator and prey",
|
||||||
|
blurb: "Two coupled Stocks whose loops make them oscillate.",
|
||||||
|
build: predatorPrey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Epidemic",
|
||||||
|
blurb: "Susceptible → Infected → Recovered: a chain of Stocks, no clouds.",
|
||||||
|
build: epidemic,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Tragedy of the commons",
|
||||||
|
blurb: "Two Reinforcing appetites drain one shared Stock: a system trap.",
|
||||||
|
build: tragedyOfTheCommons,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Escalation",
|
||||||
|
blurb: "An arms race: one Reinforcing loop spanning two Stocks.",
|
||||||
|
build: escalation,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user