@loadmap/editor 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +11 -2
- package/dist/index.d.ts +60 -0
- package/dist/index.js +517 -0
- package/dist/templates-D8CUjoNO.js +41 -0
- package/dist/templates.d.ts +9 -0
- package/dist/templates.js +1 -0
- package/package.json +51 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jaime Rojas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @loadmap/editor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`<LoadmapEditor>`: a `.loadmap` definition in CodeMirror next to its live diagram, with errors under the line,
|
|
4
|
+
completions, share links, download, and a menu of templates to start from.
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
import { LoadmapEditor } from '@loadmap/editor'
|
|
8
|
+
|
|
9
|
+
;<LoadmapEditor initial={source} onChange={saveDraft} />
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Docs and a live editor: https://jaim3r0jas.github.io/loadmap/ · MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { TEMPLATES, Template } from "./templates.js";
|
|
2
|
+
import { HighlightStyle, StreamLanguage } from "@codemirror/language";
|
|
3
|
+
import { Extension } from "@codemirror/state";
|
|
4
|
+
import { CompileOptions, CompileResult, HostFunctions } from "@loadmap/dsl";
|
|
5
|
+
import { LoadmapHandle, Theme } from "@loadmap/react";
|
|
6
|
+
import { ReactNode, Ref } from "react";
|
|
7
|
+
import { ParamValue } from "@loadmap/core";
|
|
8
|
+
/** What a page can drive from outside. */
|
|
9
|
+
interface LoadmapEditorHandle {
|
|
10
|
+
source(): string;
|
|
11
|
+
/** Replaces the whole text, e.g. with an example. */
|
|
12
|
+
setSource(source: string): void;
|
|
13
|
+
/** The preview, once a version has compiled. */
|
|
14
|
+
loadmap(): LoadmapHandle | null;
|
|
15
|
+
/** The hash of a link to this definition at the current values: `v1.…&load=30`. */
|
|
16
|
+
shareHash(): Promise<string>;
|
|
17
|
+
}
|
|
18
|
+
interface LoadmapEditorProps {
|
|
19
|
+
/** The text to start from. */
|
|
20
|
+
initial: string;
|
|
21
|
+
/** Values to start the preview at; texts (from a shared link) are read by each parameter's kind. */
|
|
22
|
+
values?: Readonly<Record<string, ParamValue>>;
|
|
23
|
+
/** Functions formulas may call beyond the built-in ones. */
|
|
24
|
+
functions?: HostFunctions;
|
|
25
|
+
theme?: Theme;
|
|
26
|
+
/** Every change of the text: a page can keep a draft. */
|
|
27
|
+
onChange?: (source: string) => void;
|
|
28
|
+
/** Called with a share hash. Default: puts it in the address bar and copies the link. */
|
|
29
|
+
onShare?: (hash: string) => void | Promise<void>;
|
|
30
|
+
/** More buttons for the bar: export, record. */
|
|
31
|
+
actions?: ReactNode;
|
|
32
|
+
/** The starters in the "New from template" menu; false hides it. Default: the six that come with the editor. */
|
|
33
|
+
templates?: readonly Template[] | false;
|
|
34
|
+
/** The name the downloaded file takes, without ".loadmap". Default: the title. */
|
|
35
|
+
fileName?: string;
|
|
36
|
+
ref?: Ref<LoadmapEditorHandle>;
|
|
37
|
+
}
|
|
38
|
+
/** A text editor for a `.loadmap` definition next to its live diagram. */
|
|
39
|
+
export declare function LoadmapEditor({ initial, values, functions, theme, onChange, onShare, actions, fileName, templates, ref }: LoadmapEditorProps): import("react").JSX.Element;
|
|
40
|
+
interface State {
|
|
41
|
+
/** Inside a template that runs past the end of the line. */
|
|
42
|
+
template: boolean;
|
|
43
|
+
/** The last token was a statement's keyword: the next name is what it declares. */
|
|
44
|
+
declares: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** Highlighting that reads the language the way the parser's lexer does. */
|
|
47
|
+
export declare const loadmapStream: StreamLanguage<State>;
|
|
48
|
+
export declare const loadmapHighlight: HighlightStyle;
|
|
49
|
+
/**
|
|
50
|
+
* The language for CodeMirror: highlighting, inline errors from the checker and autocomplete. `onResult` hears every
|
|
51
|
+
* compile the linter runs (after a pause in typing), so a preview can follow without compiling twice.
|
|
52
|
+
*/
|
|
53
|
+
export declare function loadmapLanguage(o?: CompileOptions & {
|
|
54
|
+
onResult?: (source: string, result: CompileResult) => void;
|
|
55
|
+
delay?: number;
|
|
56
|
+
}): Extension;
|
|
57
|
+
/** The editor's layout. It sits inside `.lm`, so it takes loadmap's theme tokens; the preview nests its own `.lm`. */
|
|
58
|
+
export declare const EDITOR_CSS = "\n.lme { grid-template-rows: auto minmax(0, 1fr); }\n.lme-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }\n.lme-grow { flex: 1; }\n.lme-status { font: 500 .8rem var(--lm-font); color: var(--lm-drop); }\n.lme-status[data-ok=\"true\"] { color: var(--lm-ok); }\n.lme-note { font-size: .8rem; color: var(--lm-muted); }\n.lme-split { display: grid; grid-template-columns: minmax(320px, 2fr) minmax(0, 3fr); gap: 14px; align-items: start; min-width: 0; }\n@container (max-width: 980px) { .lme-split { grid-template-columns: minmax(0, 1fr); } }\n.lme-left { display: grid; gap: 8px; min-width: 0; position: sticky; top: 8px; }\n@container (max-width: 980px) { .lme-left { position: static; } }\n.lme-code { background: var(--lm-surface); border: 1px solid var(--lm-line); border-radius: 8px; overflow: hidden; min-width: 0; }\n.lme-code .cm-editor { height: min(78vh, 900px); font: 13px/1.5 var(--lm-mono); color: var(--lm-fg); background: var(--lm-surface); }\n@container (max-width: 980px) { .lme-code .cm-editor { height: 55vh; } }\n.lme-code .cm-editor.cm-focused { outline: 2px solid var(--lm-accent); outline-offset: -2px; }\n.lme-code .cm-scroller { font-family: var(--lm-mono); }\n.lme-code .cm-gutters { background: var(--lm-bg); color: var(--lm-muted); border-right: 1px solid var(--lm-line); }\n.lme-code .cm-activeLine { background: color-mix(in srgb, var(--lm-accent-soft) 45%, transparent); }\n.lme-code .cm-activeLineGutter { background: var(--lm-accent-soft); }\n.lme-code .cm-cursor { border-left-color: var(--lm-fg); }\n.lme-code .cm-selectionBackground, .lme-code .cm-focused .cm-selectionBackground { background: var(--lm-accent-soft) !important; }\n.lme-code .cm-tooltip { background: var(--lm-surface); color: var(--lm-fg); border: 1px solid var(--lm-line); border-radius: 6px; font-family: var(--lm-font); }\n.lme-code .cm-tooltip-autocomplete ul li[aria-selected] { background: var(--lm-accent); color: var(--lm-surface); }\n.lme-code .cm-completionInfo { max-width: 340px; white-space: pre-wrap; font-size: .78rem; }\n.lme-code .cm-diagnostic { font-family: var(--lm-font); font-size: .8rem; }\n.lme-code .cm-diagnostic-error { border-left-color: var(--lm-drop); }\n.lme-code .cm-diagnostic-warning { border-left-color: var(--lm-warn); }\n.lme-code .cm-lintRange-error { background-image: none; text-decoration: wavy underline var(--lm-drop); text-underline-offset: 3px; }\n.lme-code .cm-lintRange-warning { background-image: none; text-decoration: wavy underline var(--lm-warn); text-underline-offset: 3px; }\n.lme-problems { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; max-height: 22vh; overflow: auto; }\n.lme-problems button { all: unset; display: block; cursor: pointer; font-size: .8rem; padding: 4px 8px; border-left: 3px solid var(--lm-drop); background: var(--lm-surface); border-radius: 0 4px 4px 0; }\n.lme-problems li[data-severity=\"warning\"] button { border-left-color: var(--lm-warn); }\n.lme-problems button:focus-visible { outline: 2px solid var(--lm-accent); }\n.lme-problems small { display: block; color: var(--lm-muted); }\n.lme-at { font-family: var(--lm-mono); color: var(--lm-muted); }\n.lme-preview { min-width: 0; position: relative; }\n.lme-preview > .lm { padding: 0; }\n.lme-preview[data-stale] > .lm { opacity: .55; }\n.lme-stale { margin: 0 0 8px; font-size: .8rem; color: var(--lm-warn); }\n.lme-empty { margin: 0; padding: 40px 16px; text-align: center; color: var(--lm-muted); border: 1px dashed var(--lm-line); border-radius: 8px; }\n";
|
|
59
|
+
export declare function injectEditorStyles(doc?: Document): void;
|
|
60
|
+
export { type LoadmapEditorHandle, type LoadmapEditorProps, TEMPLATES, type Template };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { t as TEMPLATES } from "./templates-D8CUjoNO.js";
|
|
2
|
+
import { defaultKeymap, history, historyKeymap, indentWithTab, isolateHistory } from "@codemirror/commands";
|
|
3
|
+
import { HighlightStyle, StreamLanguage, bracketMatching, indentOnInput, syntaxHighlighting } from "@codemirror/language";
|
|
4
|
+
import { EditorState } from "@codemirror/state";
|
|
5
|
+
import { EditorView, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers } from "@codemirror/view";
|
|
6
|
+
import { compile, completions, encodeShare, problems } from "@loadmap/dsl";
|
|
7
|
+
import { BUILTINS, Reporter, SCHEMA, lex } from "@loadmap/dsl/internal";
|
|
8
|
+
import { Loadmap } from "@loadmap/react";
|
|
9
|
+
import { useEffect, useImperativeHandle, useRef, useState } from "react";
|
|
10
|
+
import { autocompletion } from "@codemirror/autocomplete";
|
|
11
|
+
import { lintGutter, linter } from "@codemirror/lint";
|
|
12
|
+
import { tags } from "@lezer/highlight";
|
|
13
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
|
+
// src/language.ts
|
|
15
|
+
const KEYWORDS = new Set(Object.keys(SCHEMA).filter(Boolean));
|
|
16
|
+
const FUNCTIONS = new Set(Object.keys(BUILTINS));
|
|
17
|
+
function template(stream, state) {
|
|
18
|
+
while (!stream.eol()) {
|
|
19
|
+
const c = stream.next();
|
|
20
|
+
if (c === "\\") stream.next();
|
|
21
|
+
else if (c === "`") {
|
|
22
|
+
state.template = false;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
state.template = true;
|
|
27
|
+
}
|
|
28
|
+
/** Highlighting that reads the language the way the parser's lexer does. */
|
|
29
|
+
const loadmapStream = StreamLanguage.define({
|
|
30
|
+
name: "loadmap",
|
|
31
|
+
startState: () => ({
|
|
32
|
+
template: false,
|
|
33
|
+
declares: false
|
|
34
|
+
}),
|
|
35
|
+
token(stream, state) {
|
|
36
|
+
if (state.template) {
|
|
37
|
+
template(stream, state);
|
|
38
|
+
return "string";
|
|
39
|
+
}
|
|
40
|
+
if (stream.eatSpace()) return null;
|
|
41
|
+
const declares = state.declares;
|
|
42
|
+
state.declares = false;
|
|
43
|
+
if (stream.match("#")) {
|
|
44
|
+
stream.skipToEnd();
|
|
45
|
+
return "comment";
|
|
46
|
+
}
|
|
47
|
+
if (stream.match(/^"(?:[^"\\]|\\.)*"?/) || stream.match(/^'(?:[^'\\]|\\.)*'?/)) return "string";
|
|
48
|
+
if (stream.eat("`")) {
|
|
49
|
+
template(stream, state);
|
|
50
|
+
return "string";
|
|
51
|
+
}
|
|
52
|
+
if (stream.match(/^\d[\d_]*(\.\d[\d_]*)?([eE][+-]?\d+)?/)) return "number";
|
|
53
|
+
if (stream.match("->")) return "operator";
|
|
54
|
+
if (stream.match(/^[A-Za-z_]\w*/)) {
|
|
55
|
+
const w = stream.current();
|
|
56
|
+
if (w === "true" || w === "false") return "atom";
|
|
57
|
+
if (stream.match(/^\s*:/, false)) return "property";
|
|
58
|
+
const head = stream.string.slice(0, stream.start).trimEnd();
|
|
59
|
+
if (KEYWORDS.has(w) && (head === "" || head.endsWith("{") || head.endsWith(";"))) {
|
|
60
|
+
state.declares = true;
|
|
61
|
+
return "keyword";
|
|
62
|
+
}
|
|
63
|
+
if (declares) return "def";
|
|
64
|
+
if (FUNCTIONS.has(w) && stream.match(/^\s*\(/, false)) return "builtin";
|
|
65
|
+
return "variable";
|
|
66
|
+
}
|
|
67
|
+
stream.next();
|
|
68
|
+
return null;
|
|
69
|
+
},
|
|
70
|
+
languageData: { commentTokens: { line: "#" } }
|
|
71
|
+
});
|
|
72
|
+
const loadmapHighlight = HighlightStyle.define([
|
|
73
|
+
{
|
|
74
|
+
tag: tags.keyword,
|
|
75
|
+
color: "var(--lm-accent)",
|
|
76
|
+
fontWeight: "600"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
tag: tags.definition(tags.variableName),
|
|
80
|
+
color: "var(--lm-fg)",
|
|
81
|
+
fontWeight: "600"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
tag: tags.propertyName,
|
|
85
|
+
color: "var(--lm-alt)"
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
tag: tags.string,
|
|
89
|
+
color: "var(--lm-ok)"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
tag: [
|
|
93
|
+
tags.number,
|
|
94
|
+
tags.bool,
|
|
95
|
+
tags.atom
|
|
96
|
+
],
|
|
97
|
+
color: "var(--lm-warn)"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
tag: tags.comment,
|
|
101
|
+
color: "var(--lm-muted)",
|
|
102
|
+
fontStyle: "italic"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
tag: tags.operator,
|
|
106
|
+
color: "var(--lm-accent)"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
tag: tags.standard(tags.variableName),
|
|
110
|
+
color: "var(--lm-accent)"
|
|
111
|
+
}
|
|
112
|
+
]);
|
|
113
|
+
/**
|
|
114
|
+
* The language for CodeMirror: highlighting, inline errors from the checker and autocomplete. `onResult` hears every
|
|
115
|
+
* compile the linter runs (after a pause in typing), so a preview can follow without compiling twice.
|
|
116
|
+
*/
|
|
117
|
+
function loadmapLanguage(o = {}) {
|
|
118
|
+
let last = null;
|
|
119
|
+
const result = (source) => {
|
|
120
|
+
if (last?.source !== source) last = {
|
|
121
|
+
source,
|
|
122
|
+
result: compile(source, o)
|
|
123
|
+
};
|
|
124
|
+
return last.result;
|
|
125
|
+
};
|
|
126
|
+
const lint = (view) => {
|
|
127
|
+
const source = view.state.doc.toString();
|
|
128
|
+
const r = result(source);
|
|
129
|
+
o.onResult?.(source, r);
|
|
130
|
+
const end = source.length;
|
|
131
|
+
return problems(r).map((d) => ({
|
|
132
|
+
from: Math.min(d.from, end),
|
|
133
|
+
to: Math.min(Math.max(d.to, d.from), end),
|
|
134
|
+
severity: d.severity,
|
|
135
|
+
message: d.hint ? `${d.message}: ${d.hint}` : d.message
|
|
136
|
+
}));
|
|
137
|
+
};
|
|
138
|
+
const complete = (ctx) => {
|
|
139
|
+
const source = ctx.state.doc.toString();
|
|
140
|
+
const c = completions(source, ctx.pos, result(source));
|
|
141
|
+
const typed = c.from < c.to || /[.:>]\s*$/.test(source.slice(Math.max(0, c.from - 3), c.from));
|
|
142
|
+
if (!c.options.length || !ctx.explicit && !typed) return null;
|
|
143
|
+
return {
|
|
144
|
+
from: c.from,
|
|
145
|
+
to: c.to,
|
|
146
|
+
options: c.options,
|
|
147
|
+
validFor: /^\w*$/
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
return [
|
|
151
|
+
loadmapStream,
|
|
152
|
+
syntaxHighlighting(loadmapHighlight),
|
|
153
|
+
linter(lint, { delay: o.delay ?? 150 }),
|
|
154
|
+
lintGutter(),
|
|
155
|
+
autocompletion({
|
|
156
|
+
override: [complete],
|
|
157
|
+
activateOnTyping: true
|
|
158
|
+
})
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
// src/styles.ts
|
|
162
|
+
/** The editor's layout. It sits inside `.lm`, so it takes loadmap's theme tokens; the preview nests its own `.lm`. */
|
|
163
|
+
const EDITOR_CSS = `
|
|
164
|
+
.lme { grid-template-rows: auto minmax(0, 1fr); }
|
|
165
|
+
.lme-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
166
|
+
.lme-grow { flex: 1; }
|
|
167
|
+
.lme-status { font: 500 .8rem var(--lm-font); color: var(--lm-drop); }
|
|
168
|
+
.lme-status[data-ok="true"] { color: var(--lm-ok); }
|
|
169
|
+
.lme-note { font-size: .8rem; color: var(--lm-muted); }
|
|
170
|
+
.lme-split { display: grid; grid-template-columns: minmax(320px, 2fr) minmax(0, 3fr); gap: 14px; align-items: start; min-width: 0; }
|
|
171
|
+
@container (max-width: 980px) { .lme-split { grid-template-columns: minmax(0, 1fr); } }
|
|
172
|
+
.lme-left { display: grid; gap: 8px; min-width: 0; position: sticky; top: 8px; }
|
|
173
|
+
@container (max-width: 980px) { .lme-left { position: static; } }
|
|
174
|
+
.lme-code { background: var(--lm-surface); border: 1px solid var(--lm-line); border-radius: 8px; overflow: hidden; min-width: 0; }
|
|
175
|
+
.lme-code .cm-editor { height: min(78vh, 900px); font: 13px/1.5 var(--lm-mono); color: var(--lm-fg); background: var(--lm-surface); }
|
|
176
|
+
@container (max-width: 980px) { .lme-code .cm-editor { height: 55vh; } }
|
|
177
|
+
.lme-code .cm-editor.cm-focused { outline: 2px solid var(--lm-accent); outline-offset: -2px; }
|
|
178
|
+
.lme-code .cm-scroller { font-family: var(--lm-mono); }
|
|
179
|
+
.lme-code .cm-gutters { background: var(--lm-bg); color: var(--lm-muted); border-right: 1px solid var(--lm-line); }
|
|
180
|
+
.lme-code .cm-activeLine { background: color-mix(in srgb, var(--lm-accent-soft) 45%, transparent); }
|
|
181
|
+
.lme-code .cm-activeLineGutter { background: var(--lm-accent-soft); }
|
|
182
|
+
.lme-code .cm-cursor { border-left-color: var(--lm-fg); }
|
|
183
|
+
.lme-code .cm-selectionBackground, .lme-code .cm-focused .cm-selectionBackground { background: var(--lm-accent-soft) !important; }
|
|
184
|
+
.lme-code .cm-tooltip { background: var(--lm-surface); color: var(--lm-fg); border: 1px solid var(--lm-line); border-radius: 6px; font-family: var(--lm-font); }
|
|
185
|
+
.lme-code .cm-tooltip-autocomplete ul li[aria-selected] { background: var(--lm-accent); color: var(--lm-surface); }
|
|
186
|
+
.lme-code .cm-completionInfo { max-width: 340px; white-space: pre-wrap; font-size: .78rem; }
|
|
187
|
+
.lme-code .cm-diagnostic { font-family: var(--lm-font); font-size: .8rem; }
|
|
188
|
+
.lme-code .cm-diagnostic-error { border-left-color: var(--lm-drop); }
|
|
189
|
+
.lme-code .cm-diagnostic-warning { border-left-color: var(--lm-warn); }
|
|
190
|
+
.lme-code .cm-lintRange-error { background-image: none; text-decoration: wavy underline var(--lm-drop); text-underline-offset: 3px; }
|
|
191
|
+
.lme-code .cm-lintRange-warning { background-image: none; text-decoration: wavy underline var(--lm-warn); text-underline-offset: 3px; }
|
|
192
|
+
.lme-problems { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; max-height: 22vh; overflow: auto; }
|
|
193
|
+
.lme-problems button { all: unset; display: block; cursor: pointer; font-size: .8rem; padding: 4px 8px; border-left: 3px solid var(--lm-drop); background: var(--lm-surface); border-radius: 0 4px 4px 0; }
|
|
194
|
+
.lme-problems li[data-severity="warning"] button { border-left-color: var(--lm-warn); }
|
|
195
|
+
.lme-problems button:focus-visible { outline: 2px solid var(--lm-accent); }
|
|
196
|
+
.lme-problems small { display: block; color: var(--lm-muted); }
|
|
197
|
+
.lme-at { font-family: var(--lm-mono); color: var(--lm-muted); }
|
|
198
|
+
.lme-preview { min-width: 0; position: relative; }
|
|
199
|
+
.lme-preview > .lm { padding: 0; }
|
|
200
|
+
.lme-preview[data-stale] > .lm { opacity: .55; }
|
|
201
|
+
.lme-stale { margin: 0 0 8px; font-size: .8rem; color: var(--lm-warn); }
|
|
202
|
+
.lme-empty { margin: 0; padding: 40px 16px; text-align: center; color: var(--lm-muted); border: 1px dashed var(--lm-line); border-radius: 8px; }
|
|
203
|
+
`;
|
|
204
|
+
function injectEditorStyles(doc = document) {
|
|
205
|
+
if (doc.getElementById("loadmap-editor-css")) return;
|
|
206
|
+
const el = doc.createElement("style");
|
|
207
|
+
el.id = "loadmap-editor-css";
|
|
208
|
+
el.textContent = EDITOR_CSS;
|
|
209
|
+
doc.head.appendChild(el);
|
|
210
|
+
}
|
|
211
|
+
// src/LoadmapEditor.tsx
|
|
212
|
+
/** A text editor for a `.loadmap` definition next to its live diagram. */
|
|
213
|
+
function LoadmapEditor({ initial, values, functions, theme, onChange, onShare, actions, fileName, templates = TEMPLATES, ref }) {
|
|
214
|
+
useEffect(() => injectEditorStyles(), []);
|
|
215
|
+
const [state, setState] = useState(() => {
|
|
216
|
+
const result = compile(initial, { functions });
|
|
217
|
+
return {
|
|
218
|
+
source: initial,
|
|
219
|
+
result,
|
|
220
|
+
good: result.definition ?? null,
|
|
221
|
+
goodKey: fingerprint(initial)
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
const [note, setNote] = useState("");
|
|
225
|
+
const host = useRef(null);
|
|
226
|
+
const view = useRef(null);
|
|
227
|
+
const preview = useRef(null);
|
|
228
|
+
const shown = useRef(values ?? {});
|
|
229
|
+
const fresh = useRef(false);
|
|
230
|
+
const loaded = useRef(initial);
|
|
231
|
+
const latest = useRef({
|
|
232
|
+
onChange,
|
|
233
|
+
functions
|
|
234
|
+
});
|
|
235
|
+
latest.current = {
|
|
236
|
+
onChange,
|
|
237
|
+
functions
|
|
238
|
+
};
|
|
239
|
+
const accept = (source, result) => {
|
|
240
|
+
const h = preview.current;
|
|
241
|
+
if (fresh.current) fresh.current = false;
|
|
242
|
+
else if (h) shown.current = h.values();
|
|
243
|
+
setState((prev) => {
|
|
244
|
+
if (prev.source === source && prev.result === result) return prev;
|
|
245
|
+
if (!result.definition) return {
|
|
246
|
+
...prev,
|
|
247
|
+
source,
|
|
248
|
+
result
|
|
249
|
+
};
|
|
250
|
+
const key = fingerprint(source);
|
|
251
|
+
if (key === prev.goodKey && prev.good) return {
|
|
252
|
+
...prev,
|
|
253
|
+
source,
|
|
254
|
+
result
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
source,
|
|
258
|
+
result,
|
|
259
|
+
good: result.definition,
|
|
260
|
+
goodKey: key
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
const acceptRef = useRef(accept);
|
|
265
|
+
acceptRef.current = accept;
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
if (!host.current) return;
|
|
268
|
+
const v = new EditorView({
|
|
269
|
+
parent: host.current,
|
|
270
|
+
state: EditorState.create({
|
|
271
|
+
doc: initial,
|
|
272
|
+
extensions: [
|
|
273
|
+
lineNumbers(),
|
|
274
|
+
highlightActiveLineGutter(),
|
|
275
|
+
highlightActiveLine(),
|
|
276
|
+
history(),
|
|
277
|
+
indentOnInput(),
|
|
278
|
+
bracketMatching(),
|
|
279
|
+
keymap.of([
|
|
280
|
+
...defaultKeymap,
|
|
281
|
+
...historyKeymap,
|
|
282
|
+
indentWithTab
|
|
283
|
+
]),
|
|
284
|
+
EditorState.tabSize.of(2),
|
|
285
|
+
EditorView.lineWrapping,
|
|
286
|
+
EditorView.contentAttributes.of({ "aria-label": "loadmap definition" }),
|
|
287
|
+
loadmapLanguage({
|
|
288
|
+
functions: latest.current.functions,
|
|
289
|
+
onResult: (source, result) => acceptRef.current(source, result)
|
|
290
|
+
}),
|
|
291
|
+
EditorView.updateListener.of((u) => {
|
|
292
|
+
if (u.docChanged) latest.current.onChange?.(u.state.doc.toString());
|
|
293
|
+
})
|
|
294
|
+
]
|
|
295
|
+
})
|
|
296
|
+
});
|
|
297
|
+
view.current = v;
|
|
298
|
+
return () => {
|
|
299
|
+
v.destroy();
|
|
300
|
+
view.current = null;
|
|
301
|
+
};
|
|
302
|
+
}, []);
|
|
303
|
+
const def = state.good;
|
|
304
|
+
/** A whole new text, from its own defaults. One transaction: undo brings the old text back. */
|
|
305
|
+
const replace = (source) => {
|
|
306
|
+
const v = view.current;
|
|
307
|
+
if (!v) return;
|
|
308
|
+
loaded.current = source;
|
|
309
|
+
shown.current = {};
|
|
310
|
+
fresh.current = true;
|
|
311
|
+
v.dispatch({
|
|
312
|
+
changes: {
|
|
313
|
+
from: 0,
|
|
314
|
+
to: v.state.doc.length,
|
|
315
|
+
insert: source
|
|
316
|
+
},
|
|
317
|
+
selection: { anchor: 0 },
|
|
318
|
+
annotations: isolateHistory.of("full")
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
const startFrom = (t) => {
|
|
322
|
+
const text = view.current?.state.doc.toString();
|
|
323
|
+
if (text != null && text !== loaded.current && !window.confirm(`Replace your text with “${t.title}”?`)) return;
|
|
324
|
+
replace(t.source);
|
|
325
|
+
setNote(`New from “${t.title}”`);
|
|
326
|
+
};
|
|
327
|
+
const shareHash = async () => {
|
|
328
|
+
const h = preview.current;
|
|
329
|
+
const changed = {};
|
|
330
|
+
if (h && def) {
|
|
331
|
+
const defaults = def.model.defaults();
|
|
332
|
+
for (const [k, v] of Object.entries(h.values())) if (defaults[k] !== v) changed[k] = v;
|
|
333
|
+
}
|
|
334
|
+
return encodeShare(view.current?.state.doc.toString() ?? state.source, changed);
|
|
335
|
+
};
|
|
336
|
+
useImperativeHandle(ref, () => ({
|
|
337
|
+
source: () => view.current?.state.doc.toString() ?? state.source,
|
|
338
|
+
setSource: replace,
|
|
339
|
+
loadmap: () => preview.current,
|
|
340
|
+
shareHash
|
|
341
|
+
}));
|
|
342
|
+
const share = async () => {
|
|
343
|
+
const hash = await shareHash();
|
|
344
|
+
if (onShare) await onShare(hash);
|
|
345
|
+
else {
|
|
346
|
+
window.history.replaceState(null, "", `#${hash}`);
|
|
347
|
+
try {
|
|
348
|
+
await navigator.clipboard.writeText(location.href);
|
|
349
|
+
setNote("Link copied");
|
|
350
|
+
} catch {
|
|
351
|
+
setNote("Link in the address bar");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
const save = () => {
|
|
356
|
+
const text = view.current?.state.doc.toString() ?? state.source;
|
|
357
|
+
const name = fileName ?? slug(def?.model.title ?? "") ?? "diagram";
|
|
358
|
+
const a = document.createElement("a");
|
|
359
|
+
a.href = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
|
|
360
|
+
a.download = `${name || "diagram"}.loadmap`;
|
|
361
|
+
a.click();
|
|
362
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1e3);
|
|
363
|
+
};
|
|
364
|
+
const goTo = (d) => {
|
|
365
|
+
const v = view.current;
|
|
366
|
+
if (!v) return;
|
|
367
|
+
v.dispatch({
|
|
368
|
+
selection: { anchor: Math.min(d.from, v.state.doc.length) },
|
|
369
|
+
scrollIntoView: true
|
|
370
|
+
});
|
|
371
|
+
v.focus();
|
|
372
|
+
};
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (!note) return;
|
|
375
|
+
const t = setTimeout(() => setNote(""), 2500);
|
|
376
|
+
return () => clearTimeout(t);
|
|
377
|
+
}, [note]);
|
|
378
|
+
const list = problems(state.result);
|
|
379
|
+
const errors = list.filter((d) => d.severity === "error").length;
|
|
380
|
+
const warnings = list.length - errors;
|
|
381
|
+
const stale = !state.result.ok && !!def;
|
|
382
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
383
|
+
className: "lm lme",
|
|
384
|
+
"data-theme": theme === "auto" || !theme ? void 0 : theme,
|
|
385
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
386
|
+
className: "lme-bar",
|
|
387
|
+
children: [
|
|
388
|
+
/* @__PURE__ */ jsx("output", {
|
|
389
|
+
className: "lme-status",
|
|
390
|
+
"data-ok": errors === 0,
|
|
391
|
+
children: errors === 0 && warnings === 0 ? "No problems" : [count(errors, "error"), count(warnings, "warning")].filter(Boolean).join(" · ")
|
|
392
|
+
}),
|
|
393
|
+
/* @__PURE__ */ jsx("span", {
|
|
394
|
+
className: "lme-note",
|
|
395
|
+
"aria-live": "polite",
|
|
396
|
+
children: note
|
|
397
|
+
}),
|
|
398
|
+
/* @__PURE__ */ jsx("span", { className: "lme-grow" }),
|
|
399
|
+
templates && templates.length > 0 && /* @__PURE__ */ jsxs("select", {
|
|
400
|
+
className: "lm-btn",
|
|
401
|
+
"aria-label": "New from template",
|
|
402
|
+
value: "",
|
|
403
|
+
onChange: (e) => {
|
|
404
|
+
const t = templates.find((x) => x.id === e.target.value);
|
|
405
|
+
if (t) startFrom(t);
|
|
406
|
+
},
|
|
407
|
+
children: [/* @__PURE__ */ jsx("option", {
|
|
408
|
+
value: "",
|
|
409
|
+
children: "New from template…"
|
|
410
|
+
}), templates.map((t) => /* @__PURE__ */ jsx("option", {
|
|
411
|
+
value: t.id,
|
|
412
|
+
title: t.summary,
|
|
413
|
+
children: t.title
|
|
414
|
+
}, t.id))]
|
|
415
|
+
}),
|
|
416
|
+
/* @__PURE__ */ jsx("button", {
|
|
417
|
+
type: "button",
|
|
418
|
+
className: "lm-btn",
|
|
419
|
+
onClick: share,
|
|
420
|
+
children: "Share link"
|
|
421
|
+
}),
|
|
422
|
+
/* @__PURE__ */ jsx("button", {
|
|
423
|
+
type: "button",
|
|
424
|
+
className: "lm-btn",
|
|
425
|
+
onClick: save,
|
|
426
|
+
children: "Download .loadmap"
|
|
427
|
+
}),
|
|
428
|
+
actions
|
|
429
|
+
]
|
|
430
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
431
|
+
className: "lme-split",
|
|
432
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
433
|
+
className: "lme-left",
|
|
434
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
435
|
+
className: "lme-code",
|
|
436
|
+
ref: host
|
|
437
|
+
}), list.length > 0 && /* @__PURE__ */ jsx("ul", {
|
|
438
|
+
className: "lme-problems",
|
|
439
|
+
children: list.map((d) => /* @__PURE__ */ jsx("li", {
|
|
440
|
+
"data-severity": d.severity,
|
|
441
|
+
children: /* @__PURE__ */ jsxs("button", {
|
|
442
|
+
type: "button",
|
|
443
|
+
onClick: () => goTo(d),
|
|
444
|
+
children: [
|
|
445
|
+
/* @__PURE__ */ jsxs("span", {
|
|
446
|
+
className: "lme-at",
|
|
447
|
+
children: [
|
|
448
|
+
d.line,
|
|
449
|
+
":",
|
|
450
|
+
d.column
|
|
451
|
+
]
|
|
452
|
+
}),
|
|
453
|
+
" ",
|
|
454
|
+
d.message,
|
|
455
|
+
d.hint && /* @__PURE__ */ jsx("small", { children: d.hint })
|
|
456
|
+
]
|
|
457
|
+
})
|
|
458
|
+
}, `${d.from}:${d.message}`))
|
|
459
|
+
})]
|
|
460
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
461
|
+
className: "lme-preview",
|
|
462
|
+
"data-stale": stale || void 0,
|
|
463
|
+
children: [stale && /* @__PURE__ */ jsx("p", {
|
|
464
|
+
className: "lme-stale",
|
|
465
|
+
children: "Showing the last version without errors."
|
|
466
|
+
}), def ? /* @__PURE__ */ jsx(Loadmap, {
|
|
467
|
+
ref: preview,
|
|
468
|
+
model: def.model,
|
|
469
|
+
view: def.view,
|
|
470
|
+
theme,
|
|
471
|
+
...pageOptions(def),
|
|
472
|
+
initial: valuesFor(def.model, shown.current)
|
|
473
|
+
}) : /* @__PURE__ */ jsx("p", {
|
|
474
|
+
className: "lme-empty",
|
|
475
|
+
children: "Fix the errors on the left to see the diagram."
|
|
476
|
+
})]
|
|
477
|
+
})]
|
|
478
|
+
})]
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
/** The definition's page options, leaving out the unset ones so `<Loadmap>` keeps its defaults. */
|
|
482
|
+
function pageOptions(def) {
|
|
483
|
+
const { speed, direction, assumptions } = def.options;
|
|
484
|
+
return {
|
|
485
|
+
...speed != null ? { speed } : {},
|
|
486
|
+
...direction ? { direction } : {},
|
|
487
|
+
...assumptions.length ? { assumptions } : {}
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** The tokens of the text: two texts with the same tokens define the same diagram. */
|
|
491
|
+
function fingerprint(source) {
|
|
492
|
+
return lex(source, new Reporter(source)).map((t) => t.text).join("");
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* The values that still fit the model: known names, options that still exist, numbers within range. A text (from a
|
|
496
|
+
* link) is read by the parameter's kind, so "20" can be the number 20 or the option "20".
|
|
497
|
+
*/
|
|
498
|
+
function valuesFor(model, v) {
|
|
499
|
+
const out = {};
|
|
500
|
+
for (const { id, def } of model.params) {
|
|
501
|
+
if (!Object.hasOwn(v, id)) continue;
|
|
502
|
+
const x = v[id];
|
|
503
|
+
if (def.kind === "select") {
|
|
504
|
+
const o = def.options.find((o) => o === x || String(o) === String(x));
|
|
505
|
+
if (o !== void 0) out[id] = o;
|
|
506
|
+
}
|
|
507
|
+
if (def.kind === "switch" && (x === true || x === "true" || x === false || x === "false")) out[id] = x === true || x === "true";
|
|
508
|
+
if (def.kind === "range" || def.kind === "load") {
|
|
509
|
+
const n = typeof x === "number" ? x : typeof x === "string" && x.trim() !== "" ? Number(x) : NaN;
|
|
510
|
+
if (Number.isFinite(n)) out[id] = Math.min(def.max, Math.max(def.min, n));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
const count = (n, word) => n ? `${n} ${word}${n > 1 ? "s" : ""}` : "";
|
|
516
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || void 0;
|
|
517
|
+
export { EDITOR_CSS, LoadmapEditor, TEMPLATES, injectEditorStyles, loadmapHighlight, loadmapLanguage, loadmapStream };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/templates.ts
|
|
2
|
+
/** Starters for "New from template": each compiles, keeps its expectations and lays out without collisions. */
|
|
3
|
+
const TEMPLATES = [
|
|
4
|
+
{
|
|
5
|
+
id: "web-app",
|
|
6
|
+
title: "Web app and database",
|
|
7
|
+
summary: "App pods in front of one database.",
|
|
8
|
+
source: "loadmap 1\n# A web app on a few pods in front of one database: the smallest useful model. Replace the numbers with yours; every\n# capacity says where it came from, so the page tells which ones are guesses.\ntitle: \"Web app and database\"\nspeed: 10\nassume: \"1× is today's busiest hour.\"\n\nload \"Traffic, × today\" { max: 50; default: 1 }\n\nparam pods \"App pods\" { options: [2, 3, 4, 6, 8]; default: 3 }\nparam cache \"Cache sessions in memory\" {\n hint: \"two queries a request instead of five\"\n default: false\n}\n\nscenario \"Today\" {}\nscenario \"Proposed\" { pods: 6; cache: true }\ngrowth { rate: 0.05; per: month; asOf: \"2026-11-01\" }\n\nexpect \"Today keeps 2×\" { target: 2 }\nexpect \"Proposed keeps 4×\" { scenario: Proposed; target: 4 }\n\nlet queries = cache ? 2 : 5\n\nflow requests \"requests\" {\n unit: \"request\"\n rate: 150 * load\n route: lb -> app -> db\n from: browsers\n color: accent\n}\n\nnode browsers \"Browsers\"\n\nnode lb \"Load balancer\" {\n pass\n short: \"the load balancer\"\n demand: flows.requests\n capacity: measured(10_000, \"req/s\", \"the provider's documented limit\")\n over: \"new connections are refused\"\n}\n\npool app \"App\" `${pods} pods × 4 workers` {\n workers { pods: pods; perPod: 4 }\n short: \"the app pods\"\n plural: true\n demand: flows.requests * 0.004\n capacity: measured(pods * 1, \"cores\", \"pod CPU limit\")\n over: \"requests queue and time out\"\n describe: `${pods} pods, 1 core each, 4 ms of CPU a request`\n}\n\nnode db \"Postgres\" \"primary\" {\n shape: cylinder\n store\n short: \"the database\"\n demand: flows.requests * queries\n capacity: estimated(6_000 ± 30%, \"queries/s\", \"a load test from last year\")\n over: \"queries queue on the primary and every page slows down\"\n}\n\nbrowsers -> lb: \"HTTPS\"\nlb -> app\napp -> db: `${compact(flows.requests * queries)} queries/s`\n"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
id: "read-replicas",
|
|
12
|
+
title: "Reads on replicas",
|
|
13
|
+
summary: "Reads on replicas, writes on a primary that ships them on.",
|
|
14
|
+
source: "loadmap 1\n# Reads go to replicas, writes to the primary, and the primary also ships every write to each replica. More replicas\n# take reads off each other but add replication work on the primary.\ntitle: \"Reads on replicas\"\nspeed: 10\nassume: \"1× is today's busiest hour; reads and writes grow together.\"\n\nload \"Traffic, × today\" { max: 50; default: 1 }\n\nparam pods \"API pods\" { options: [2, 4, 6, 8]; default: 4 }\nparam replicas \"Read replicas\" { options: [1, 2, 3, 4]; default: 2 }\n\nscenario \"Today\" {}\nscenario \"Two more replicas\" { replicas: 4 }\ngrowth { rate: 0.04; per: month; asOf: \"2026-11-01\" }\n\nexpect \"Today keeps 2×\" { target: 2 }\n\nflow reads \"reads\" {\n unit: \"read\"\n rate: 1_200 * load\n route: api -> replicas\n from: clients\n color: accent\n}\n\nflow writes \"writes\" {\n unit: \"write\"\n rate: 90 * load\n priority: 1\n route: api -> primary\n from: clients\n color: alt\n}\n\nnode clients \"Clients\" \"web and mobile\"\n\npool api \"API\" `${pods} pods` {\n workers { pods: pods; perPod: 8 }\n short: \"the API\"\n demand: (flows.reads + flows.writes) * 0.0005\n capacity: measured(pods * 1, \"cores\", \"pod CPU limit\")\n over: \"requests queue in the API\"\n}\n\nnode replicas \"Read replicas\" `${replicas} replicas` {\n shape: cylinder\n store\n short: \"the read replicas\"\n plural: true\n demand: flows.reads\n capacity: estimated(replicas * 4_000 ± 30%, \"queries/s\", \"about 4,000 reads/s per replica\")\n over: \"reads slow down and fall behind the primary\"\n}\n\nnode primary \"Primary\" \"writes\" {\n shape: cylinder\n store\n short: \"the primary\"\n # every write is applied here and shipped to each replica\n demand: flows.writes * (1 + 0.25 * replicas)\n capacity: estimated(1_500 ± 30%, \"writes/s\", \"vendor sizing for this instance\")\n over: \"writes queue and replication lag grows\"\n describe: `${replicas} replicas to ship to`\n}\n\nclients -> api\napi -> replicas: \"reads\"\napi -> primary: \"writes\"\nprimary -> replicas: \"replication\"\n"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "queue-workers",
|
|
18
|
+
title: "Queue and workers",
|
|
19
|
+
summary: "An API that queues jobs for a pool of workers.",
|
|
20
|
+
source: "loadmap 1\n# Requests are accepted fast and done later: an API puts jobs on a queue, a pool of workers drains it, and each job\n# ends in the database. Past capacity the queue grows instead of requests failing.\ntitle: \"Queue and workers\"\nspeed: 10\nassume: \"1× is the peak hour of an ordinary day.\"\n\nload \"Jobs, × today\" { max: 100; default: 5 }\n\nparam pods \"Worker pods\" { options: [2, 3, 4, 6, 8]; default: 3 }\nparam threads \"Threads per pod\" { options: [4, 8, 16]; default: 8 }\n\nscenario \"Today\" {}\nscenario \"Proposed\" { pods: 6 }\ngrowth { rate: 0.06; per: month; asOf: \"2026-11-01\" }\n\nexpect \"Today keeps 10×\" { target: 10 }\nexpect \"Proposed keeps 20×\" { scenario: Proposed; target: 20 }\n\nflow jobs \"jobs\" {\n unit: \"job\"\n rate: 20 * load\n route: api -> topic -> workers -> db\n from: clients\n color: accent\n}\n\nnode clients \"Clients\"\n\nnode api \"API\" {\n pass\n short: \"the API\"\n demand: flows.jobs\n capacity: measured(3_000, \"req/s\", \"load test, March\")\n over: \"clients get 503s\"\n}\n\ncluster broker \"Queue\" \"jobs topic\" {\n members: 3\n memberLabel: `broker ${i}`\n queue topic { partitions { jobs: pods * threads } }\n short: \"the queue\"\n demand: flows.jobs * 4 / 1000\n capacity: estimated(30, \"MB/s\", \"vendor sizing guide\")\n over: \"producing slows down and the API backs up\"\n}\n\npool workers \"Workers\" `${pods} pods × ${threads} threads` {\n workers { pods: pods; perPod: threads }\n short: \"the workers\"\n plural: true\n demand: flows.jobs * 0.01\n capacity: measured(pods * 2, \"cores\", \"pod CPU limit\")\n over: \"jobs wait in the queue and the lag keeps growing\"\n describe: `${pods} pods, 2 cores each, 10 ms of CPU a job`\n}\n\nnode db \"Database\" {\n shape: cylinder\n store\n short: \"the database\"\n demand: flows.jobs * 3\n capacity: estimated(5_000 ± 30%, \"writes/s\")\n over: \"writes queue and the workers stall on them\"\n}\n\nclients -> api: \"POST /jobs\"\napi -> broker: `${compact(flows.jobs)} jobs/s`\nbroker -> workers\nworkers -> db\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "notifications",
|
|
24
|
+
title: "Notification fan-out",
|
|
25
|
+
summary: "One event, several messages, a quota per provider.",
|
|
26
|
+
source: "loadmap 1\n# One event becomes several messages: a sender pool turns events into push notifications, emails and texts, and each\n# provider has a quota of its own. The quota that runs out first is usually not the one with the most traffic.\ntitle: \"Notification fan-out\"\nspeed: 10\nassume: \"1× is a normal weekday peak; a campaign is a preset.\"\n\nload \"Events, × today\" { max: 100; default: 1 }\n\nparam pods \"Sender pods\" { options: [2, 4, 6]; default: 2 }\nparam emailShare \"Events that also send an email\" { options: [0.1, 0.3, 0.6]; default: 0.3 }\nparam smsPlan \"SMS plan (messages/s)\" { options: [10, 50, 200]; default: 10 }\n\npreset \"Campaign\" { load: 20; emailShare: 0.6 }\n\nscenario \"Today\" {}\nscenario \"Bigger SMS plan\" { smsPlan: 200 }\ngrowth { rate: 0.08; per: month; asOf: \"2026-11-01\" }\n\nexpect \"Today keeps 1.5×\" { target: 1.5 }\n\nflow events \"events\" {\n unit: \"event\"\n rate: 60 * load\n route: api -> topic -> senders -> dispatch\n from: apps\n color: accent\n}\n\nnode apps \"Your services\"\n\nnode api \"Events API\" {\n pass\n short: \"the events API\"\n demand: flows.events\n capacity: measured(2_000, \"req/s\", \"load test\")\n over: \"events are rejected\"\n}\n\ncluster broker \"Queue\" \"events topic\" {\n members: 3\n memberLabel: `broker ${i}`\n queue topic { partitions { events: pods * 8 } }\n}\n\npool senders \"Senders\" `${pods} pods × 8` {\n workers { pods: pods; perPod: 8 }\n short: \"the senders\"\n plural: true\n demand: flows.events * (1 + emailShare + 0.05) * 0.003\n capacity: measured(pods * 1, \"cores\", \"pod CPU limit\")\n over: \"notifications wait in the queue and arrive late\"\n}\n\nnode dispatch \"Dispatch\" {\n fanout {\n events -> push: 1\n events -> email: emailShare\n events -> sms: 0.05\n }\n}\n\nnode push \"Push service\" \"APNs and FCM\" {\n sink\n short: \"the push service\"\n demand: flows.events\n capacity: estimated(5_000, \"messages/s\", \"provider guidance\")\n over: \"pushes are throttled and arrive late\"\n}\n\nnode email \"Email provider\" {\n sink\n short: \"the email provider\"\n demand: flows.events * emailShare\n capacity: measured(200, \"messages/s\", \"plan quota\")\n over: \"emails queue at the provider; past the daily cap they are dropped\"\n}\n\nnode sms \"SMS provider\" `${smsPlan}/s plan` {\n sink\n short: \"the SMS provider\"\n demand: flows.events * 0.05\n capacity: measured(smsPlan, \"messages/s\", \"plan quota\")\n over: \"texts are rejected with 429s\"\n}\n\napps -> api\napi -> broker\nbroker -> senders\nsenders -> dispatch\ndispatch -> push\ndispatch -> email\ndispatch -> sms\n"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "batch-import",
|
|
30
|
+
title: "Batch import",
|
|
31
|
+
summary: "Upload, store, parse and load into a warehouse in batches.",
|
|
32
|
+
source: "loadmap 1\n# Files are uploaded, stored, parsed into rows and loaded into a warehouse in batches. Bigger batches cost the\n# warehouse less per row; more parsers only help until the warehouse is the limit.\ntitle: \"Batch import\"\nspeed: 10\nassume: \"1× is the nightly import; files average 5,000 rows.\"\n\nload \"Files, × today\" { max: 50; default: 1 }\n\nparam parsers \"Parser pods\" { options: [2, 4, 8, 12]; default: 4 }\nparam rows \"Rows per insert\" { options: [100, 1_000, 5_000]; default: 1_000 }\n\nscenario \"Today\" {}\nscenario \"Bigger batches\" { rows: 5_000; parsers: 8 }\ngrowth { rate: 0.1; per: month; asOf: \"2026-11-01\" }\n\nexpect \"Today keeps 2×\" { target: 2 }\n\nflow files \"files\" {\n unit: \"file\"\n rate: 4 * load\n route: upload -> bucket -> parsers -> warehouse\n from: partners\n color: accent\n}\n\nnode partners \"Partners\" \"SFTP and HTTPS\"\n\nnode upload \"Upload API\" {\n pass\n short: \"the upload API\"\n demand: flows.files\n capacity: measured(200, \"files/s\", \"load test\")\n over: \"uploads fail and partners retry\"\n}\n\nnode bucket \"Object store\" {\n pass\n short: \"the object store\"\n demand: flows.files * 2\n capacity: measured(3_500, \"requests/s\", \"provider limit per prefix\")\n over: \"writes get 503 slow-downs\"\n}\n\npool parsers \"Parsers\" `${parsers} pods` {\n workers { pods: parsers; perPod: 2 }\n short: \"the parsers\"\n plural: true\n demand: flows.files * 0.1\n capacity: measured(parsers * 1, \"cores\", \"pod CPU limit\")\n over: \"files wait in the bucket and the import runs into the morning\"\n describe: `${parsers} pods, 100 ms of CPU a file`\n}\n\nnode warehouse \"Warehouse\" {\n shape: cylinder\n store\n short: \"the warehouse\"\n demand: flows.files * 5_000 / rows\n capacity: estimated(200 ± 30%, \"inserts/s\", \"a test on a smaller instance\")\n over: \"inserts queue and the import misses its window\"\n}\n\npartners -> upload\nupload -> bucket\nbucket -> parsers\nparsers -> warehouse: `${compact(rows)} rows an insert`\n"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "third-party-api",
|
|
36
|
+
title: "Third-party API with a quota",
|
|
37
|
+
summary: "A vendor’s rate limit, a cache, and a bigger plan.",
|
|
38
|
+
source: "loadmap 1\n# Your service depends on someone else's API with a rate quota. A cache in front cuts the calls; the plan sets the\n# quota. Which one buys more headroom for the money is the question this answers.\ntitle: \"Third-party API with a quota\"\nspeed: 10\nassume: \"1× is today's busiest minute.\"\n\nload \"Traffic, × today\" { max: 100; default: 1 }\n\nparam pods \"Service pods\" { options: [2, 4, 6]; default: 2 }\nparam hit \"Cache hit ratio\" { options: [0, 0.5, 0.8]; default: 0 }\nparam quota \"Vendor plan (requests/s)\" { options: [50, 200, 1_000]; default: 50 }\n\nscenario \"Today\" {}\nscenario \"Add a cache\" { hit: 0.8 }\nscenario \"Bigger plan\" { quota: 200 }\ngrowth { rate: 0.05; per: month; asOf: \"2026-11-01\" }\n\nexpect \"Today keeps 1.5×\" { target: 1.5 }\n\nlet calls = flows.lookups * (1 - hit)\n\nflow lookups \"lookups\" {\n unit: \"lookup\"\n rate: 15 * load\n route: service -> vendor\n from: users\n color: accent\n}\n\nnode users \"Users\"\n\npool service \"Your service\" `${pods} pods` {\n workers { pods: pods; perPod: 8 }\n short: \"your service\"\n demand: flows.lookups * 0.01\n capacity: measured(pods * 1, \"cores\", \"pod CPU limit\")\n over: \"requests queue in your service\"\n}\n\nnode vendor \"Vendor API\" `${quota} req/s plan` {\n store\n short: \"the vendor API\"\n demand: calls\n capacity: measured(quota, \"req/s\", \"the plan's rate limit\")\n over: \"calls get 429s and lookups fail\"\n describe: hit > 0 ? `${hit * 100} % served from the cache` : \"every lookup calls it\"\n}\n\nusers -> service\nservice -> vendor: `${compact(calls)} calls/s`\n"
|
|
39
|
+
}
|
|
40
|
+
];
|
|
41
|
+
export { TEMPLATES as t };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface Template {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
/** One line for a menu or a gallery card. */
|
|
5
|
+
summary: string;
|
|
6
|
+
source: string;
|
|
7
|
+
}
|
|
8
|
+
/** Starters for "New from template": each compiles, keeps its expectations and lays out without collisions. */
|
|
9
|
+
export declare const TEMPLATES: readonly Template[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { t as TEMPLATES } from "./templates-D8CUjoNO.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loadmap/editor",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "<LoadmapEditor>: a .loadmap editor next to its live diagram, with inline errors, completions and share links.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"loadmap",
|
|
7
|
+
"editor",
|
|
8
|
+
"codemirror",
|
|
9
|
+
"react"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "Jaime Rojas",
|
|
13
|
+
"homepage": "https://jaim3r0jas.github.io/loadmap/",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/jaim3r0jas/loadmap.git",
|
|
17
|
+
"directory": "packages/editor"
|
|
18
|
+
},
|
|
19
|
+
"bugs": "https://github.com/jaim3r0jas/loadmap/issues",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./templates": {
|
|
28
|
+
"types": "./dist/templates.d.ts",
|
|
29
|
+
"import": "./dist/templates.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@codemirror/autocomplete": "^6.20.4",
|
|
37
|
+
"@codemirror/commands": "^6.11.1",
|
|
38
|
+
"@codemirror/language": "^6.13.1",
|
|
39
|
+
"@codemirror/lint": "^6.9.7",
|
|
40
|
+
"@codemirror/state": "^6.7.6",
|
|
41
|
+
"@codemirror/view": "^6.43.15",
|
|
42
|
+
"@lezer/highlight": "^1.2.5",
|
|
43
|
+
"@loadmap/core": "0.1.0",
|
|
44
|
+
"@loadmap/dsl": "0.1.0",
|
|
45
|
+
"@loadmap/react": "0.1.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"react": "^19",
|
|
49
|
+
"react-dom": "^19"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsdown"
|
|
53
|
+
}
|
|
6
54
|
}
|