@tanishqkancharla/diffmap 0.4.0 → 0.6.1

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.
@@ -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: State;
59
- previousState: State;
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={widest.get(service.name)!}
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.get(service.name)!}
355
- previous={props.previousState.get(service.name)!}
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
- {(["idle", "active"] as const).map((kind) => (
379
+ {tones.map((tone) => (
368
380
  <marker
369
- key={kind}
370
- id={`${markerId}-${kind}`}
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.some(
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 marker = (active: boolean) =>
397
- `url(#${markerId}-${active ? "active" : "idle"})`;
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
- forward || backward ? colors.accent[9] : colors.gray[7],
408
- strokeWidth: forward || backward ? 2 : 1,
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,24 +533,30 @@ 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.label;
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;
539
+ // A step's events leave in order, each when the one before it arrives.
540
+ const delay = props.phase === "travel" ? (props.index * TRAVEL_MS) / 1000 : 0;
528
541
  return (
529
542
  <>
530
543
  {props.phase === "travel" && (
531
544
  <motion.div
532
545
  className={packet}
533
- initial={{ x: props.points[0]!.x, y: props.points[0]!.y }}
546
+ data-change={props.event.sign}
547
+ initial={{ x: props.points[0]!.x, y: props.points[0]!.y, opacity: 0 }}
534
548
  animate={{
535
549
  x: props.points.map((point) => point.x),
536
550
  y: props.points.map((point) => point.y),
551
+ opacity: 1,
537
552
  }}
538
553
  // Even speed along every segment of the route.
539
554
  transition={{
555
+ delay,
540
556
  duration: TRAVEL_MS / 1000,
541
557
  ease: "easeInOut",
542
558
  times: lengths.map((length) => length / total),
559
+ opacity: { delay, duration: 0 },
543
560
  }}
544
561
  />
545
562
  )}
@@ -551,20 +568,24 @@ function EventMarks(
551
568
  data-event-label={props.index}
552
569
  initial={{ opacity: 0 }}
553
570
  animate={{ opacity: 1 }}
571
+ transition={{ delay }}
554
572
  >
555
573
  {label !== undefined &&
556
574
  (annotation === undefined ? (
557
- <div className={pill}>{label}</div>
575
+ <div className={pill} data-change={props.event.sign}>
576
+ {label}
577
+ </div>
558
578
  ) : (
559
579
  <button
560
580
  type="button"
561
581
  className={pill}
582
+ data-change={props.event.sign}
562
583
  aria-pressed={props.selectedAnnotation === annotation}
563
584
  aria-controls="source-diff-panel"
564
585
  aria-label={`Open source: ${annotation.text}`}
565
586
  onClick={() => props.onSelectAnnotation(annotation)}
566
587
  >
567
- {label} ↗
588
+ {label}
568
589
  </button>
569
590
  ))}
570
591
  </motion.div>
@@ -577,8 +598,8 @@ function EventMarks(
577
598
  function ServiceCard(
578
599
  props: {
579
600
  service: Service;
580
- values: Map<string, string>;
581
- previous: Map<string, string> | undefined;
601
+ values: Values;
602
+ previous: Values | undefined;
582
603
  changed: Set<string>;
583
604
  } & SourceNavigation,
584
605
  ) {
@@ -595,7 +616,10 @@ function ServiceCard(
595
616
  <Text
596
617
  size="sm"
597
618
  fontWeight={600}
598
- style={{ whiteSpace: "nowrap" }}
619
+ style={{
620
+ whiteSpace: "nowrap",
621
+ textDecorationLine: service.sign === "-" ? "line-through" : undefined,
622
+ }}
599
623
  data-described={described}
600
624
  >
601
625
  {service.name}
@@ -639,13 +663,18 @@ function ServiceCard(
639
663
  ["--key-width" as string]: `${Math.max(...service.state.map(({ key }) => key.length))}ch`,
640
664
  }}
641
665
  >
642
- {service.state.map(({ key, description }) => {
666
+ {service.state.map(({ key, description, sign }) => {
643
667
  const changed = props.changed.has(`${service.name}.${key}`);
668
+ // A row added or removed in a diff shows only its own side.
669
+ const side = sign === "-" ? "today" : "proposed";
670
+ const today = props.values.today.get(key);
671
+ const proposed = props.values.proposed.get(key);
644
672
  return (
645
673
  <div
646
674
  key={key}
647
675
  className={stateRow}
648
676
  data-changed={changed}
677
+ data-change={sign}
649
678
  data-state-row={`${service.name}.${key}`}
650
679
  >
651
680
  <Described description={description}>
@@ -657,18 +686,39 @@ function ServiceCard(
657
686
  </dt>
658
687
  </Described>
659
688
  <dd>
660
- {changed && (
661
- <Text size="xs" monospace color="lowContrast">
662
- <s>{props.previous?.get(key)}</s>{" "}
663
- </Text>
689
+ {sign === " " && today !== proposed ? (
690
+ <>
691
+ <Text
692
+ size="xs"
693
+ monospace
694
+ style={{ color: colors.red[11] }}
695
+ >
696
+ <s>{today}</s>{" "}
697
+ </Text>
698
+ <Text
699
+ size="xs"
700
+ monospace
701
+ style={{ color: colors.green[11] }}
702
+ >
703
+ {proposed}
704
+ </Text>
705
+ </>
706
+ ) : (
707
+ <>
708
+ {changed && (
709
+ <Text size="xs" monospace color="lowContrast">
710
+ <s>{props.previous?.[side].get(key)}</s>{" "}
711
+ </Text>
712
+ )}
713
+ <Text
714
+ size="xs"
715
+ monospace
716
+ color={changed ? "accent" : undefined}
717
+ >
718
+ {props.values[side].get(key)}
719
+ </Text>
720
+ </>
664
721
  )}
665
- <Text
666
- size="xs"
667
- monospace
668
- color={changed ? "accent" : undefined}
669
- >
670
- {props.values.get(key)}
671
- </Text>
672
722
  </dd>
673
723
  </div>
674
724
  );
@@ -694,30 +744,91 @@ export function Described(props: {
694
744
 
695
745
  /**
696
746
  * Each state value at its widest, including the struck-through old value a
697
- * changed row shows beside the new one.
747
+ * changed row shows beside the new one, and a diff's Today value beside its
748
+ * Proposed one.
698
749
  */
699
750
  function widestState(diagram: ServiceDiagram) {
700
- const current = new Map(
701
- flattenServices(diagram.services).map(({ service }) => [
702
- service.name,
703
- new Map(service.state.map((entry) => [entry.key, entry.value])),
704
- ]),
705
- );
751
+ const flat = flattenServices(diagram.services);
706
752
  const widest = new Map(
707
- [...current].map(([name, values]) => [name, new Map(values)]),
753
+ flat.map(({ service }) => [service.name, new Map<string, string>()]),
708
754
  );
709
- for (const step of diagram.steps)
710
- for (const update of step.updates) {
711
- const values = current.get(update.service)!;
712
- const shown = `${values.get(update.key)} ${update.value}`;
713
- const best = widest.get(update.service)!;
714
- if (shown.length > best.get(update.key)!.length)
715
- best.set(update.key, shown);
716
- values.set(update.key, update.value);
717
- }
755
+ const at = (step: number) => ({
756
+ today: serviceStateAt(diagram, step, "today"),
757
+ proposed: serviceStateAt(diagram, step, "proposed"),
758
+ });
759
+ let before = at(0);
760
+ for (let step = 0; step <= diagram.steps.length; step++) {
761
+ const now = step === 0 ? before : at(step);
762
+ // A row updated in this step shows its old value struck through.
763
+ const updated = new Set(
764
+ diagram.steps[step - 1]?.updates.map(
765
+ (update) => `${update.service}.${update.key}`,
766
+ ),
767
+ );
768
+ for (const { service } of flat)
769
+ for (const { key } of service.state) {
770
+ const best = widest.get(service.name)!;
771
+ const value = (state: State) => state.get(service.name)!.get(key);
772
+ const today = value(now.today);
773
+ const proposed = value(now.proposed);
774
+ const shown = [
775
+ today === undefined || proposed === undefined || today === proposed
776
+ ? (proposed ?? today ?? "")
777
+ : `${today} ${proposed}`,
778
+ ];
779
+ if (updated.has(`${service.name}.${key}`))
780
+ shown.push(
781
+ `${value(before.today) ?? ""} ${today ?? ""}`,
782
+ `${value(before.proposed) ?? ""} ${proposed ?? ""}`,
783
+ );
784
+ for (const candidate of shown)
785
+ if (candidate.length > (best.get(key)?.length ?? -1))
786
+ best.set(key, candidate);
787
+ }
788
+ before = now;
789
+ }
718
790
  return widest;
719
791
  }
720
792
 
793
+ function valuesOf(state: SidedState, service: string): Values {
794
+ return {
795
+ today: state.today.get(service)!,
796
+ proposed: state.proposed.get(service)!,
797
+ };
798
+ }
799
+
800
+ /** The sign every event shares, or " " when they differ. */
801
+ function commonSign(events: ServiceEvent[]): Sign {
802
+ return events.every((event) => event.sign === events[0]!.sign)
803
+ ? events[0]!.sign
804
+ : " ";
805
+ }
806
+
807
+ /** Link colors: idle or carrying events, and Today only, Proposed only, or both. */
808
+ const tones = (["idle", "active"] as const).flatMap((state) =>
809
+ (["+", "-", " "] as const).map((sign) => {
810
+ const scale =
811
+ sign === "+" ? colors.green : sign === "-" ? colors.red : undefined;
812
+ const active = state === "active";
813
+ const line = active
814
+ ? (scale?.[9] ?? colors.accent[9])
815
+ : (scale?.[8] ?? colors.gray[7]);
816
+ return {
817
+ id: `${state}-${{ "+": "added", "-": "removed", " ": "same" }[sign]}`,
818
+ state,
819
+ sign,
820
+ line,
821
+ head: active || scale !== undefined ? line : colors.gray[8],
822
+ };
823
+ }),
824
+ );
825
+
826
+ function toneFor(active: boolean, sign: Sign) {
827
+ return tones.find(
828
+ (tone) => tone.sign === sign && (tone.state === "active") === active,
829
+ )!;
830
+ }
831
+
721
832
  function isAncestor(
722
833
  parents: Map<string, string | undefined>,
723
834
  ancestor: string,
@@ -806,7 +917,6 @@ const described = {
806
917
  textDecorationStyle: "dotted" as const,
807
918
  textDecorationColor: colors.gray[8],
808
919
  textUnderlineOffset: "3px",
809
- cursor: "help",
810
920
  },
811
921
  };
812
922
 
@@ -878,7 +988,18 @@ const styles = {
878
988
  "&[data-depth='1'], &[data-depth='3']": {
879
989
  backgroundColor: colors.gray[2],
880
990
  },
991
+ "&[data-change='+']": {
992
+ borderColor: colors.green[8],
993
+ backgroundColor: colors.green[2],
994
+ },
995
+ "&[data-change='-']": {
996
+ borderColor: colors.red[8],
997
+ borderStyle: "dashed",
998
+ backgroundColor: colors.red[2],
999
+ },
881
1000
  "&[data-active='true']": { borderColor: colors.accent[8] },
1001
+ "&[data-active='true'][data-change='+']": { borderColor: colors.green[10] },
1002
+ "&[data-active='true'][data-change='-']": { borderColor: colors.red[10] },
882
1003
  }),
883
1004
  header: style(
884
1005
  flex({ direction: "row", alignItems: "baseline", gap: 3, wrap: true }),
@@ -886,7 +1007,7 @@ const styles = {
886
1007
  focusRing(),
887
1008
  {
888
1009
  minWidth: 0,
889
- "& > [data-described]": described["&[data-described='true']"],
1010
+ "& > [data-described='true']": described["&[data-described='true']"],
890
1011
  },
891
1012
  ),
892
1013
  headerButton: style({
@@ -928,6 +1049,14 @@ const styles = {
928
1049
  columnGap: spacing.value(4),
929
1050
  paddingInline: spacing.value(2),
930
1051
  marginInline: `-${spacing.value(2)}`,
1052
+ "&[data-change='+']": {
1053
+ backgroundColor: colors.greenAlpha[3],
1054
+ boxShadow: `inset 2px 0 ${colors.green[9]}`,
1055
+ },
1056
+ "&[data-change='-']": {
1057
+ backgroundColor: colors.redAlpha[3],
1058
+ boxShadow: `inset 2px 0 ${colors.red[9]}`,
1059
+ },
931
1060
  "&[data-changed='true']": { backgroundColor: colors.accentAlpha[3] },
932
1061
  }),
933
1062
  packet: style(radius.circle, {
@@ -940,6 +1069,14 @@ const styles = {
940
1069
  boxShadow: `0 0 0 3px ${colors.accentAlpha[5]}`,
941
1070
  pointerEvents: "none",
942
1071
  zIndex: 2,
1072
+ "&[data-change='+']": {
1073
+ backgroundColor: colors.green[9],
1074
+ boxShadow: `0 0 0 3px ${colors.greenAlpha[5]}`,
1075
+ },
1076
+ "&[data-change='-']": {
1077
+ backgroundColor: colors.red[9],
1078
+ boxShadow: `0 0 0 3px ${colors.redAlpha[5]}`,
1079
+ },
943
1080
  }),
944
1081
  labelAnchor: style({
945
1082
  position: "absolute",
@@ -960,6 +1097,15 @@ const styles = {
960
1097
  border: `1px solid ${colors.accent[7]}`,
961
1098
  backgroundColor: backgroundColor.app,
962
1099
  color: colors.accent[11],
1100
+ "&[data-change='+']": {
1101
+ borderColor: colors.green[7],
1102
+ color: colors.green[11],
1103
+ },
1104
+ "&[data-change='-']": {
1105
+ borderColor: colors.red[7],
1106
+ borderStyle: "dashed",
1107
+ color: colors.red[11],
1108
+ },
963
1109
  "&:is(button)": { cursor: "pointer" },
964
1110
  "&:is(button):hover": { backgroundColor: colors.accent[3] },
965
1111
  "&[aria-pressed='true']": { backgroundColor: colors.blue[3] },