@tanishqkancharla/diffmap 0.4.0 → 0.6.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/README.md +76 -2
- package/package.json +1 -1
- package/src/annotations.ts +17 -1
- package/src/extractDocument.ts +10 -2
- package/src/host/home.md +82 -83
- package/src/host/home.test.ts +0 -1
- package/src/parseFence.ts +81 -12
- package/src/parseViewer.test.ts +147 -4
- package/src/parseViewer.ts +44 -9
- package/src/serve.test.ts +22 -1
- package/src/services.ts +120 -13
- package/src/viewer/CallStackDiff.tsx +30 -32
- package/src/viewer/ChangesCard.tsx +0 -2
- package/src/viewer/CodeBlock.tsx +79 -6
- package/src/viewer/ComarkView.tsx +34 -0
- package/src/viewer/Fence.tsx +4 -0
- package/src/viewer/HtmlBlock.tsx +2 -1
- package/src/viewer/MermaidBlock.tsx +55 -14
- package/src/viewer/ServiceDiagramBlock.tsx +87 -52
- package/src/viewer/ServiceStage.tsx +198 -57
- package/src/viewer/SourceDiffPanel.tsx +138 -42
- package/src/viewer/TableOfContents.tsx +44 -18
- package/src/viewer/TocButton.tsx +28 -0
- package/src/viewer/ViewerApp.tsx +10 -0
- package/src/viewer/diagramDiff.ts +69 -0
- package/src/viewer/diagramLinks.ts +54 -6
- package/src/viewer/pierre.ts +20 -2
- package/src/viewer/serviceLayout.ts +16 -5
- package/vite.config.ts +4 -0
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
RotateAntiClockwise,
|
|
10
10
|
Text,
|
|
11
11
|
Tooltip,
|
|
12
|
-
borderColor,
|
|
13
12
|
colors,
|
|
14
13
|
flex,
|
|
15
14
|
focusRing,
|
|
@@ -19,6 +18,7 @@ import {
|
|
|
19
18
|
} from "maui";
|
|
20
19
|
import { style, useStyles } from "purse-styles";
|
|
21
20
|
import type { SourceNavigation } from "../annotations.js";
|
|
21
|
+
import { fenceTitle, pierreShell } from "./pierre.js";
|
|
22
22
|
import { ServiceStage, TRAVEL_MS } from "./ServiceStage.tsx";
|
|
23
23
|
import {
|
|
24
24
|
serviceStateAt,
|
|
@@ -28,10 +28,16 @@ import {
|
|
|
28
28
|
|
|
29
29
|
const DWELL_MS = 1200;
|
|
30
30
|
|
|
31
|
+
/** Names a diff step's side before its caption. */
|
|
32
|
+
const signLabel = { "+": "Added: ", "-": "Removed: ", " ": "" } as const;
|
|
33
|
+
|
|
31
34
|
type Phase = "travel" | "settled";
|
|
32
35
|
|
|
33
36
|
export function ServiceDiagramBlock(
|
|
34
|
-
props: {
|
|
37
|
+
props: {
|
|
38
|
+
source: string;
|
|
39
|
+
parsed: ServicesParse;
|
|
40
|
+
} & SourceNavigation,
|
|
35
41
|
) {
|
|
36
42
|
const error = useStyles(styles.error);
|
|
37
43
|
if (!props.parsed.ok)
|
|
@@ -61,13 +67,16 @@ function ServicePlayer(props: { diagram: ServiceDiagram } & SourceNavigation) {
|
|
|
61
67
|
const [playing, setPlaying] = useState(false);
|
|
62
68
|
const states = useMemo(
|
|
63
69
|
() =>
|
|
64
|
-
Array.from({ length: last + 1 }, (_, step) =>
|
|
65
|
-
serviceStateAt(diagram, step),
|
|
66
|
-
|
|
70
|
+
Array.from({ length: last + 1 }, (_, step) => ({
|
|
71
|
+
today: serviceStateAt(diagram, step, "today"),
|
|
72
|
+
proposed: serviceStateAt(diagram, step, "proposed"),
|
|
73
|
+
})),
|
|
67
74
|
[diagram, last],
|
|
68
75
|
);
|
|
69
76
|
const step = position.step;
|
|
70
77
|
const current = step === 0 ? undefined : diagram.steps[step - 1]!;
|
|
78
|
+
// Before step 1, the author's start caption, if any, explains the state.
|
|
79
|
+
const captioned = current ?? diagram.start;
|
|
71
80
|
const settled = position.phase === "settled";
|
|
72
81
|
// In flight, the events have not landed, so show the previous state.
|
|
73
82
|
const shownState = states[settled ? step : step - 1]!;
|
|
@@ -129,33 +138,51 @@ function ServicePlayer(props: { diagram: ServiceDiagram } & SourceNavigation) {
|
|
|
129
138
|
}
|
|
130
139
|
};
|
|
131
140
|
|
|
132
|
-
const frame = useStyles(styles.frame);
|
|
141
|
+
const frame = useStyles(pierreShell, styles.frame);
|
|
142
|
+
const body = useStyles(styles.body);
|
|
133
143
|
const controls = useStyles(styles.controls);
|
|
134
144
|
const scrubber = useStyles(styles.scrubber);
|
|
135
145
|
const segment = useStyles(styles.segment);
|
|
136
|
-
const detail = useStyles(styles.detail);
|
|
146
|
+
const detail = useStyles(fenceTitle, styles.detail);
|
|
137
147
|
const counter = useStyles(styles.counter);
|
|
138
148
|
|
|
139
149
|
return (
|
|
140
|
-
<
|
|
150
|
+
<figure
|
|
141
151
|
className={frame}
|
|
142
152
|
data-diffmap-kind="services"
|
|
143
153
|
// Capture: Maui's Button stops arrow keys from bubbling.
|
|
144
154
|
onKeyDownCapture={onKeyDown}
|
|
145
155
|
>
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
{/* The caption leads: before step 1 it is the start caption, which
|
|
157
|
+
captions the whole diagram. */}
|
|
158
|
+
{(captioned !== undefined || last > 0) && (
|
|
159
|
+
<figcaption className={detail} aria-live="polite">
|
|
160
|
+
{captioned !== undefined && (
|
|
161
|
+
<Text size="sm" fontWeight={500}>
|
|
162
|
+
{current !== undefined && current.sign !== " " && (
|
|
163
|
+
<Text
|
|
164
|
+
size="sm"
|
|
165
|
+
fontWeight={600}
|
|
166
|
+
style={{
|
|
167
|
+
color:
|
|
168
|
+
current.sign === "+" ? colors.green[11] : colors.red[11],
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{signLabel[current.sign]}
|
|
172
|
+
</Text>
|
|
173
|
+
)}
|
|
174
|
+
{captioned.caption}
|
|
175
|
+
</Text>
|
|
176
|
+
)}
|
|
177
|
+
{captioned?.description !== undefined && (
|
|
178
|
+
<Text size="xs" color="lowContrast">
|
|
179
|
+
{captioned.description}
|
|
180
|
+
</Text>
|
|
181
|
+
)}
|
|
182
|
+
</figcaption>
|
|
183
|
+
)}
|
|
184
|
+
<div className={body}>
|
|
185
|
+
{last > 0 && (
|
|
159
186
|
<div className={controls}>
|
|
160
187
|
<Tooltip content="Restart" placement="top">
|
|
161
188
|
<Button
|
|
@@ -220,6 +247,7 @@ function ServicePlayer(props: { diagram: ServiceDiagram } & SourceNavigation) {
|
|
|
220
247
|
<button
|
|
221
248
|
type="button"
|
|
222
249
|
className={segment}
|
|
250
|
+
data-change={entry.sign}
|
|
223
251
|
data-state={
|
|
224
252
|
index + 1 === step
|
|
225
253
|
? "current"
|
|
@@ -227,7 +255,7 @@ function ServicePlayer(props: { diagram: ServiceDiagram } & SourceNavigation) {
|
|
|
227
255
|
? "done"
|
|
228
256
|
: "todo"
|
|
229
257
|
}
|
|
230
|
-
aria-label={`Step ${index + 1}: ${entry.caption}`}
|
|
258
|
+
aria-label={`Step ${index + 1}: ${signLabel[entry.sign]}${entry.caption}`}
|
|
231
259
|
aria-current={index + 1 === step ? "step" : undefined}
|
|
232
260
|
onClick={() => {
|
|
233
261
|
setPlaying(false);
|
|
@@ -241,24 +269,20 @@ function ServicePlayer(props: { diagram: ServiceDiagram } & SourceNavigation) {
|
|
|
241
269
|
{step} / {last}
|
|
242
270
|
</Text>
|
|
243
271
|
</div>
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
</div>
|
|
259
|
-
</>
|
|
260
|
-
)}
|
|
261
|
-
</div>
|
|
272
|
+
)}
|
|
273
|
+
<ServiceStage
|
|
274
|
+
{...props}
|
|
275
|
+
state={shownState}
|
|
276
|
+
previousState={states[Math.max(step - 1, 0)]!}
|
|
277
|
+
changed={changed}
|
|
278
|
+
active={active}
|
|
279
|
+
events={current?.events ?? []}
|
|
280
|
+
updates={current?.updates ?? []}
|
|
281
|
+
phase={position.phase}
|
|
282
|
+
stepKey={step}
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
</figure>
|
|
262
286
|
);
|
|
263
287
|
}
|
|
264
288
|
|
|
@@ -273,17 +297,16 @@ function positionFor(
|
|
|
273
297
|
|
|
274
298
|
const styles = {
|
|
275
299
|
error: style(text({ size: "sm", color: "lowContrast" })),
|
|
276
|
-
|
|
300
|
+
// A card like a code block: the caption row, then the controls and stage.
|
|
301
|
+
frame: style({ width: "100%", margin: 0 }),
|
|
302
|
+
body: style(
|
|
277
303
|
flex({ direction: "column", gap: 4 }),
|
|
278
|
-
radius.lg,
|
|
279
304
|
spacing.padding({ all: 6 }),
|
|
280
|
-
{
|
|
281
|
-
width: "100%",
|
|
282
|
-
minWidth: 0,
|
|
283
|
-
border: `1px solid ${borderColor.outline}`,
|
|
284
|
-
},
|
|
285
305
|
),
|
|
286
|
-
controls: style(flex({ direction: "row", alignItems: "center", gap: 2 })
|
|
306
|
+
controls: style(flex({ direction: "row", alignItems: "center", gap: 2 }), {
|
|
307
|
+
// Buttons keep their full label; only the step scrubber gives up width.
|
|
308
|
+
"& > button": { flexShrink: 0, overflow: "visible" },
|
|
309
|
+
}),
|
|
287
310
|
scrubber: style(flex({ direction: "row", alignItems: "center", gap: 1 }), {
|
|
288
311
|
flex: "1 1 auto",
|
|
289
312
|
minWidth: 0,
|
|
@@ -312,12 +335,24 @@ const styles = {
|
|
|
312
335
|
"&:hover::after": { backgroundColor: colors.gray[7] },
|
|
313
336
|
"&[data-state='done']::after": { backgroundColor: colors.accent[7] },
|
|
314
337
|
"&[data-state='current']::after": { backgroundColor: colors.accent[9] },
|
|
338
|
+
"&[data-change='+']::after": { backgroundColor: colors.green[6] },
|
|
339
|
+
"&[data-change='+'][data-state='done']::after": {
|
|
340
|
+
backgroundColor: colors.green[8],
|
|
341
|
+
},
|
|
342
|
+
"&[data-change='+'][data-state='current']::after": {
|
|
343
|
+
backgroundColor: colors.green[10],
|
|
344
|
+
},
|
|
345
|
+
"&[data-change='-']::after": { backgroundColor: colors.red[6] },
|
|
346
|
+
"&[data-change='-'][data-state='done']::after": {
|
|
347
|
+
backgroundColor: colors.red[8],
|
|
348
|
+
},
|
|
349
|
+
"&[data-change='-'][data-state='current']::after": {
|
|
350
|
+
backgroundColor: colors.red[10],
|
|
351
|
+
},
|
|
315
352
|
},
|
|
316
353
|
focusRing(),
|
|
317
354
|
),
|
|
318
355
|
counter: style({ flexShrink: 0, minWidth: "5ch", textAlign: "end" }),
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
paddingInline: spacing.value(2),
|
|
322
|
-
}),
|
|
356
|
+
// Holds two lines so the controls below don't move between steps.
|
|
357
|
+
detail: style(flex({ direction: "column", gap: 2 }), { minHeight: 44 }),
|
|
323
358
|
};
|
|
@@ -22,14 +22,17 @@ import { style, useStyles } from "purse-styles";
|
|
|
22
22
|
import type { SourceNavigation } from "../annotations.js";
|
|
23
23
|
import {
|
|
24
24
|
flattenServices,
|
|
25
|
+
serviceStateAt,
|
|
25
26
|
type Service,
|
|
26
27
|
type ServiceDiagram,
|
|
27
28
|
type ServiceEvent,
|
|
28
29
|
type ServiceUpdate,
|
|
30
|
+
type Sign,
|
|
29
31
|
} from "../services.js";
|
|
30
32
|
import {
|
|
31
33
|
layoutServices,
|
|
32
34
|
linkId,
|
|
35
|
+
pillText,
|
|
33
36
|
serviceLinks,
|
|
34
37
|
type Point,
|
|
35
38
|
type Rect,
|
|
@@ -53,10 +56,15 @@ type StepNote = {
|
|
|
53
56
|
|
|
54
57
|
type State = Map<string, Map<string, string>>;
|
|
55
58
|
|
|
59
|
+
/** State replayed on each side of a diff; the sides match outside a diff. */
|
|
60
|
+
export type SidedState = { today: State; proposed: State };
|
|
61
|
+
|
|
62
|
+
type Values = { today: Map<string, string>; proposed: Map<string, string> };
|
|
63
|
+
|
|
56
64
|
export type StageProps = {
|
|
57
65
|
diagram: ServiceDiagram;
|
|
58
|
-
state:
|
|
59
|
-
previousState:
|
|
66
|
+
state: SidedState;
|
|
67
|
+
previousState: SidedState;
|
|
60
68
|
changed: Set<string>;
|
|
61
69
|
active: Set<string>;
|
|
62
70
|
events: ServiceEvent[];
|
|
@@ -306,7 +314,10 @@ export function ServiceStage(props: StageProps) {
|
|
|
306
314
|
<ServiceCard
|
|
307
315
|
{...props}
|
|
308
316
|
service={service}
|
|
309
|
-
values={
|
|
317
|
+
values={{
|
|
318
|
+
today: widest.get(service.name)!,
|
|
319
|
+
proposed: widest.get(service.name)!,
|
|
320
|
+
}}
|
|
310
321
|
previous={undefined}
|
|
311
322
|
changed={new Set()}
|
|
312
323
|
/>
|
|
@@ -339,6 +350,7 @@ export function ServiceStage(props: StageProps) {
|
|
|
339
350
|
key={service.name}
|
|
340
351
|
className={box}
|
|
341
352
|
data-depth={depth.get(service.name)}
|
|
353
|
+
data-change={service.sign}
|
|
342
354
|
data-active={props.active.has(service.name)}
|
|
343
355
|
aria-label={service.name}
|
|
344
356
|
style={{
|
|
@@ -351,8 +363,8 @@ export function ServiceStage(props: StageProps) {
|
|
|
351
363
|
<ServiceCard
|
|
352
364
|
{...props}
|
|
353
365
|
service={service}
|
|
354
|
-
values={props.state
|
|
355
|
-
previous={props.previousState
|
|
366
|
+
values={valuesOf(props.state, service.name)}
|
|
367
|
+
previous={valuesOf(props.previousState, service.name)}
|
|
356
368
|
/>
|
|
357
369
|
</section>
|
|
358
370
|
);
|
|
@@ -364,10 +376,10 @@ export function ServiceStage(props: StageProps) {
|
|
|
364
376
|
aria-hidden="true"
|
|
365
377
|
>
|
|
366
378
|
<defs>
|
|
367
|
-
{
|
|
379
|
+
{tones.map((tone) => (
|
|
368
380
|
<marker
|
|
369
|
-
key={
|
|
370
|
-
id={`${markerId}-${
|
|
381
|
+
key={tone.id}
|
|
382
|
+
id={`${markerId}-${tone.id}`}
|
|
371
383
|
viewBox="0 0 10 10"
|
|
372
384
|
refX="9"
|
|
373
385
|
refY="5"
|
|
@@ -375,26 +387,26 @@ export function ServiceStage(props: StageProps) {
|
|
|
375
387
|
markerHeight="7"
|
|
376
388
|
orient="auto-start-reverse"
|
|
377
389
|
>
|
|
378
|
-
<path
|
|
379
|
-
d="M0,1 L9,5 L0,9 z"
|
|
380
|
-
style={{
|
|
381
|
-
fill:
|
|
382
|
-
kind === "active" ? colors.accent[9] : colors.gray[8],
|
|
383
|
-
}}
|
|
384
|
-
/>
|
|
390
|
+
<path d="M0,1 L9,5 L0,9 z" style={{ fill: tone.head }} />
|
|
385
391
|
</marker>
|
|
386
392
|
))}
|
|
387
393
|
</defs>
|
|
388
394
|
{links.map((link) => {
|
|
389
395
|
const route = trimmedRoute(layout, link, parents);
|
|
390
396
|
const sending = (from: string, to: string) =>
|
|
391
|
-
props.events.
|
|
397
|
+
props.events.filter(
|
|
392
398
|
(event) => event.from === from && event.to === to,
|
|
393
399
|
);
|
|
394
400
|
const forward = sending(link.a, link.b);
|
|
395
401
|
const backward = sending(link.b, link.a);
|
|
396
|
-
const
|
|
397
|
-
|
|
402
|
+
const active = forward.length > 0 || backward.length > 0;
|
|
403
|
+
// While its events travel, a link takes their sign.
|
|
404
|
+
const tone = toneFor(
|
|
405
|
+
active,
|
|
406
|
+
active ? commonSign([...forward, ...backward]) : link.sign,
|
|
407
|
+
);
|
|
408
|
+
const marker = (sent: ServiceEvent[]) =>
|
|
409
|
+
`url(#${markerId}-${(sent.length > 0 ? tone : toneFor(false, link.sign)).id})`;
|
|
398
410
|
return (
|
|
399
411
|
<path
|
|
400
412
|
key={link.id}
|
|
@@ -403,10 +415,9 @@ export function ServiceStage(props: StageProps) {
|
|
|
403
415
|
markerEnd={link.forward ? marker(forward) : undefined}
|
|
404
416
|
markerStart={link.backward ? marker(backward) : undefined}
|
|
405
417
|
style={{
|
|
406
|
-
stroke:
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
strokeDasharray: forward || backward ? undefined : "4 4",
|
|
418
|
+
stroke: tone.line,
|
|
419
|
+
strokeWidth: active ? 2 : 1,
|
|
420
|
+
strokeDasharray: active ? undefined : "4 4",
|
|
410
421
|
}}
|
|
411
422
|
/>
|
|
412
423
|
);
|
|
@@ -522,7 +533,7 @@ function EventMarks(
|
|
|
522
533
|
const labelAnchor = useStyles(styles.labelAnchor);
|
|
523
534
|
const pill = useStyles(styles.pill);
|
|
524
535
|
const annotation = props.event.annotation;
|
|
525
|
-
const label = props.event.label === "" ? undefined : props.event
|
|
536
|
+
const label = props.event.label === "" ? undefined : pillText(props.event);
|
|
526
537
|
const lengths = cumulativeLengths(props.points);
|
|
527
538
|
const total = lengths.at(-1)! || 1;
|
|
528
539
|
return (
|
|
@@ -530,6 +541,7 @@ function EventMarks(
|
|
|
530
541
|
{props.phase === "travel" && (
|
|
531
542
|
<motion.div
|
|
532
543
|
className={packet}
|
|
544
|
+
data-change={props.event.sign}
|
|
533
545
|
initial={{ x: props.points[0]!.x, y: props.points[0]!.y }}
|
|
534
546
|
animate={{
|
|
535
547
|
x: props.points.map((point) => point.x),
|
|
@@ -554,17 +566,20 @@ function EventMarks(
|
|
|
554
566
|
>
|
|
555
567
|
{label !== undefined &&
|
|
556
568
|
(annotation === undefined ? (
|
|
557
|
-
<div className={pill}
|
|
569
|
+
<div className={pill} data-change={props.event.sign}>
|
|
570
|
+
{label}
|
|
571
|
+
</div>
|
|
558
572
|
) : (
|
|
559
573
|
<button
|
|
560
574
|
type="button"
|
|
561
575
|
className={pill}
|
|
576
|
+
data-change={props.event.sign}
|
|
562
577
|
aria-pressed={props.selectedAnnotation === annotation}
|
|
563
578
|
aria-controls="source-diff-panel"
|
|
564
579
|
aria-label={`Open source: ${annotation.text}`}
|
|
565
580
|
onClick={() => props.onSelectAnnotation(annotation)}
|
|
566
581
|
>
|
|
567
|
-
{label}
|
|
582
|
+
{label}
|
|
568
583
|
</button>
|
|
569
584
|
))}
|
|
570
585
|
</motion.div>
|
|
@@ -577,8 +592,8 @@ function EventMarks(
|
|
|
577
592
|
function ServiceCard(
|
|
578
593
|
props: {
|
|
579
594
|
service: Service;
|
|
580
|
-
values:
|
|
581
|
-
previous:
|
|
595
|
+
values: Values;
|
|
596
|
+
previous: Values | undefined;
|
|
582
597
|
changed: Set<string>;
|
|
583
598
|
} & SourceNavigation,
|
|
584
599
|
) {
|
|
@@ -595,7 +610,10 @@ function ServiceCard(
|
|
|
595
610
|
<Text
|
|
596
611
|
size="sm"
|
|
597
612
|
fontWeight={600}
|
|
598
|
-
style={{
|
|
613
|
+
style={{
|
|
614
|
+
whiteSpace: "nowrap",
|
|
615
|
+
textDecorationLine: service.sign === "-" ? "line-through" : undefined,
|
|
616
|
+
}}
|
|
599
617
|
data-described={described}
|
|
600
618
|
>
|
|
601
619
|
{service.name}
|
|
@@ -639,13 +657,18 @@ function ServiceCard(
|
|
|
639
657
|
["--key-width" as string]: `${Math.max(...service.state.map(({ key }) => key.length))}ch`,
|
|
640
658
|
}}
|
|
641
659
|
>
|
|
642
|
-
{service.state.map(({ key, description }) => {
|
|
660
|
+
{service.state.map(({ key, description, sign }) => {
|
|
643
661
|
const changed = props.changed.has(`${service.name}.${key}`);
|
|
662
|
+
// A row added or removed in a diff shows only its own side.
|
|
663
|
+
const side = sign === "-" ? "today" : "proposed";
|
|
664
|
+
const today = props.values.today.get(key);
|
|
665
|
+
const proposed = props.values.proposed.get(key);
|
|
644
666
|
return (
|
|
645
667
|
<div
|
|
646
668
|
key={key}
|
|
647
669
|
className={stateRow}
|
|
648
670
|
data-changed={changed}
|
|
671
|
+
data-change={sign}
|
|
649
672
|
data-state-row={`${service.name}.${key}`}
|
|
650
673
|
>
|
|
651
674
|
<Described description={description}>
|
|
@@ -657,18 +680,39 @@ function ServiceCard(
|
|
|
657
680
|
</dt>
|
|
658
681
|
</Described>
|
|
659
682
|
<dd>
|
|
660
|
-
{
|
|
661
|
-
|
|
662
|
-
<
|
|
663
|
-
|
|
683
|
+
{sign === " " && today !== proposed ? (
|
|
684
|
+
<>
|
|
685
|
+
<Text
|
|
686
|
+
size="xs"
|
|
687
|
+
monospace
|
|
688
|
+
style={{ color: colors.red[11] }}
|
|
689
|
+
>
|
|
690
|
+
<s>{today}</s>{" "}
|
|
691
|
+
</Text>
|
|
692
|
+
<Text
|
|
693
|
+
size="xs"
|
|
694
|
+
monospace
|
|
695
|
+
style={{ color: colors.green[11] }}
|
|
696
|
+
>
|
|
697
|
+
{proposed}
|
|
698
|
+
</Text>
|
|
699
|
+
</>
|
|
700
|
+
) : (
|
|
701
|
+
<>
|
|
702
|
+
{changed && (
|
|
703
|
+
<Text size="xs" monospace color="lowContrast">
|
|
704
|
+
<s>{props.previous?.[side].get(key)}</s>{" "}
|
|
705
|
+
</Text>
|
|
706
|
+
)}
|
|
707
|
+
<Text
|
|
708
|
+
size="xs"
|
|
709
|
+
monospace
|
|
710
|
+
color={changed ? "accent" : undefined}
|
|
711
|
+
>
|
|
712
|
+
{props.values[side].get(key)}
|
|
713
|
+
</Text>
|
|
714
|
+
</>
|
|
664
715
|
)}
|
|
665
|
-
<Text
|
|
666
|
-
size="xs"
|
|
667
|
-
monospace
|
|
668
|
-
color={changed ? "accent" : undefined}
|
|
669
|
-
>
|
|
670
|
-
{props.values.get(key)}
|
|
671
|
-
</Text>
|
|
672
716
|
</dd>
|
|
673
717
|
</div>
|
|
674
718
|
);
|
|
@@ -694,30 +738,91 @@ export function Described(props: {
|
|
|
694
738
|
|
|
695
739
|
/**
|
|
696
740
|
* Each state value at its widest, including the struck-through old value a
|
|
697
|
-
* changed row shows beside the new one
|
|
741
|
+
* changed row shows beside the new one, and a diff's Today value beside its
|
|
742
|
+
* Proposed one.
|
|
698
743
|
*/
|
|
699
744
|
function widestState(diagram: ServiceDiagram) {
|
|
700
|
-
const
|
|
701
|
-
flattenServices(diagram.services).map(({ service }) => [
|
|
702
|
-
service.name,
|
|
703
|
-
new Map(service.state.map((entry) => [entry.key, entry.value])),
|
|
704
|
-
]),
|
|
705
|
-
);
|
|
745
|
+
const flat = flattenServices(diagram.services);
|
|
706
746
|
const widest = new Map(
|
|
707
|
-
|
|
747
|
+
flat.map(({ service }) => [service.name, new Map<string, string>()]),
|
|
708
748
|
);
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
749
|
+
const at = (step: number) => ({
|
|
750
|
+
today: serviceStateAt(diagram, step, "today"),
|
|
751
|
+
proposed: serviceStateAt(diagram, step, "proposed"),
|
|
752
|
+
});
|
|
753
|
+
let before = at(0);
|
|
754
|
+
for (let step = 0; step <= diagram.steps.length; step++) {
|
|
755
|
+
const now = step === 0 ? before : at(step);
|
|
756
|
+
// A row updated in this step shows its old value struck through.
|
|
757
|
+
const updated = new Set(
|
|
758
|
+
diagram.steps[step - 1]?.updates.map(
|
|
759
|
+
(update) => `${update.service}.${update.key}`,
|
|
760
|
+
),
|
|
761
|
+
);
|
|
762
|
+
for (const { service } of flat)
|
|
763
|
+
for (const { key } of service.state) {
|
|
764
|
+
const best = widest.get(service.name)!;
|
|
765
|
+
const value = (state: State) => state.get(service.name)!.get(key);
|
|
766
|
+
const today = value(now.today);
|
|
767
|
+
const proposed = value(now.proposed);
|
|
768
|
+
const shown = [
|
|
769
|
+
today === undefined || proposed === undefined || today === proposed
|
|
770
|
+
? (proposed ?? today ?? "")
|
|
771
|
+
: `${today} ${proposed}`,
|
|
772
|
+
];
|
|
773
|
+
if (updated.has(`${service.name}.${key}`))
|
|
774
|
+
shown.push(
|
|
775
|
+
`${value(before.today) ?? ""} ${today ?? ""}`,
|
|
776
|
+
`${value(before.proposed) ?? ""} ${proposed ?? ""}`,
|
|
777
|
+
);
|
|
778
|
+
for (const candidate of shown)
|
|
779
|
+
if (candidate.length > (best.get(key)?.length ?? -1))
|
|
780
|
+
best.set(key, candidate);
|
|
781
|
+
}
|
|
782
|
+
before = now;
|
|
783
|
+
}
|
|
718
784
|
return widest;
|
|
719
785
|
}
|
|
720
786
|
|
|
787
|
+
function valuesOf(state: SidedState, service: string): Values {
|
|
788
|
+
return {
|
|
789
|
+
today: state.today.get(service)!,
|
|
790
|
+
proposed: state.proposed.get(service)!,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** The sign every event shares, or " " when they differ. */
|
|
795
|
+
function commonSign(events: ServiceEvent[]): Sign {
|
|
796
|
+
return events.every((event) => event.sign === events[0]!.sign)
|
|
797
|
+
? events[0]!.sign
|
|
798
|
+
: " ";
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** Link colors: idle or carrying events, and Today only, Proposed only, or both. */
|
|
802
|
+
const tones = (["idle", "active"] as const).flatMap((state) =>
|
|
803
|
+
(["+", "-", " "] as const).map((sign) => {
|
|
804
|
+
const scale =
|
|
805
|
+
sign === "+" ? colors.green : sign === "-" ? colors.red : undefined;
|
|
806
|
+
const active = state === "active";
|
|
807
|
+
const line = active
|
|
808
|
+
? (scale?.[9] ?? colors.accent[9])
|
|
809
|
+
: (scale?.[8] ?? colors.gray[7]);
|
|
810
|
+
return {
|
|
811
|
+
id: `${state}-${{ "+": "added", "-": "removed", " ": "same" }[sign]}`,
|
|
812
|
+
state,
|
|
813
|
+
sign,
|
|
814
|
+
line,
|
|
815
|
+
head: active || scale !== undefined ? line : colors.gray[8],
|
|
816
|
+
};
|
|
817
|
+
}),
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
function toneFor(active: boolean, sign: Sign) {
|
|
821
|
+
return tones.find(
|
|
822
|
+
(tone) => tone.sign === sign && (tone.state === "active") === active,
|
|
823
|
+
)!;
|
|
824
|
+
}
|
|
825
|
+
|
|
721
826
|
function isAncestor(
|
|
722
827
|
parents: Map<string, string | undefined>,
|
|
723
828
|
ancestor: string,
|
|
@@ -878,7 +983,18 @@ const styles = {
|
|
|
878
983
|
"&[data-depth='1'], &[data-depth='3']": {
|
|
879
984
|
backgroundColor: colors.gray[2],
|
|
880
985
|
},
|
|
986
|
+
"&[data-change='+']": {
|
|
987
|
+
borderColor: colors.green[8],
|
|
988
|
+
backgroundColor: colors.green[2],
|
|
989
|
+
},
|
|
990
|
+
"&[data-change='-']": {
|
|
991
|
+
borderColor: colors.red[8],
|
|
992
|
+
borderStyle: "dashed",
|
|
993
|
+
backgroundColor: colors.red[2],
|
|
994
|
+
},
|
|
881
995
|
"&[data-active='true']": { borderColor: colors.accent[8] },
|
|
996
|
+
"&[data-active='true'][data-change='+']": { borderColor: colors.green[10] },
|
|
997
|
+
"&[data-active='true'][data-change='-']": { borderColor: colors.red[10] },
|
|
882
998
|
}),
|
|
883
999
|
header: style(
|
|
884
1000
|
flex({ direction: "row", alignItems: "baseline", gap: 3, wrap: true }),
|
|
@@ -928,6 +1044,14 @@ const styles = {
|
|
|
928
1044
|
columnGap: spacing.value(4),
|
|
929
1045
|
paddingInline: spacing.value(2),
|
|
930
1046
|
marginInline: `-${spacing.value(2)}`,
|
|
1047
|
+
"&[data-change='+']": {
|
|
1048
|
+
backgroundColor: colors.greenAlpha[3],
|
|
1049
|
+
boxShadow: `inset 2px 0 ${colors.green[9]}`,
|
|
1050
|
+
},
|
|
1051
|
+
"&[data-change='-']": {
|
|
1052
|
+
backgroundColor: colors.redAlpha[3],
|
|
1053
|
+
boxShadow: `inset 2px 0 ${colors.red[9]}`,
|
|
1054
|
+
},
|
|
931
1055
|
"&[data-changed='true']": { backgroundColor: colors.accentAlpha[3] },
|
|
932
1056
|
}),
|
|
933
1057
|
packet: style(radius.circle, {
|
|
@@ -940,6 +1064,14 @@ const styles = {
|
|
|
940
1064
|
boxShadow: `0 0 0 3px ${colors.accentAlpha[5]}`,
|
|
941
1065
|
pointerEvents: "none",
|
|
942
1066
|
zIndex: 2,
|
|
1067
|
+
"&[data-change='+']": {
|
|
1068
|
+
backgroundColor: colors.green[9],
|
|
1069
|
+
boxShadow: `0 0 0 3px ${colors.greenAlpha[5]}`,
|
|
1070
|
+
},
|
|
1071
|
+
"&[data-change='-']": {
|
|
1072
|
+
backgroundColor: colors.red[9],
|
|
1073
|
+
boxShadow: `0 0 0 3px ${colors.redAlpha[5]}`,
|
|
1074
|
+
},
|
|
943
1075
|
}),
|
|
944
1076
|
labelAnchor: style({
|
|
945
1077
|
position: "absolute",
|
|
@@ -960,6 +1092,15 @@ const styles = {
|
|
|
960
1092
|
border: `1px solid ${colors.accent[7]}`,
|
|
961
1093
|
backgroundColor: backgroundColor.app,
|
|
962
1094
|
color: colors.accent[11],
|
|
1095
|
+
"&[data-change='+']": {
|
|
1096
|
+
borderColor: colors.green[7],
|
|
1097
|
+
color: colors.green[11],
|
|
1098
|
+
},
|
|
1099
|
+
"&[data-change='-']": {
|
|
1100
|
+
borderColor: colors.red[7],
|
|
1101
|
+
borderStyle: "dashed",
|
|
1102
|
+
color: colors.red[11],
|
|
1103
|
+
},
|
|
963
1104
|
"&:is(button)": { cursor: "pointer" },
|
|
964
1105
|
"&:is(button):hover": { backgroundColor: colors.accent[3] },
|
|
965
1106
|
"&[aria-pressed='true']": { backgroundColor: colors.blue[3] },
|