@tellegen/svelte 0.1.0 → 0.1.2

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.
Files changed (39) hide show
  1. package/dist/TellegenMap.svelte +155 -19
  2. package/dist/TellegenMap.svelte.d.ts +1 -0
  3. package/dist/TellegenMap.svelte.d.ts.map +1 -1
  4. package/dist/TellegenShell.svelte +29 -3
  5. package/dist/TellegenShell.svelte.d.ts.map +1 -1
  6. package/dist/components/AppFooter.svelte +26 -1
  7. package/dist/components/AppFooter.svelte.d.ts +5 -17
  8. package/dist/components/AppFooter.svelte.d.ts.map +1 -1
  9. package/dist/components/AppHeader.svelte +59 -1
  10. package/dist/components/AppHeader.svelte.d.ts.map +1 -1
  11. package/dist/components/BindingLines.svelte +13 -0
  12. package/dist/components/BusPicker.svelte +40 -0
  13. package/dist/components/BusPicker.svelte.d.ts +4 -1
  14. package/dist/components/BusPicker.svelte.d.ts.map +1 -1
  15. package/dist/components/ControlPanel.svelte +359 -72
  16. package/dist/components/ControlPanel.svelte.d.ts +2 -17
  17. package/dist/components/ControlPanel.svelte.d.ts.map +1 -1
  18. package/dist/components/FormulationSelector.svelte +17 -0
  19. package/dist/components/MulticonductorDetails.svelte +58 -5
  20. package/dist/components/MulticonductorDetails.svelte.d.ts.map +1 -1
  21. package/dist/components/SensitivityReadout.svelte +10 -1
  22. package/dist/components/SolveCard.svelte +2 -1
  23. package/dist/controller.svelte.d.ts +3 -0
  24. package/dist/controller.svelte.d.ts.map +1 -1
  25. package/dist/controller.svelte.js +22 -4
  26. package/dist/multiconductor.d.ts +13 -0
  27. package/dist/multiconductor.d.ts.map +1 -1
  28. package/dist/multiconductor.js +39 -9
  29. package/dist/state.svelte.d.ts +18 -0
  30. package/dist/state.svelte.d.ts.map +1 -1
  31. package/dist/state.svelte.js +22 -0
  32. package/dist/styles.css +16 -2
  33. package/dist/synthetic-layout.d.ts +3 -1
  34. package/dist/synthetic-layout.d.ts.map +1 -1
  35. package/dist/synthetic-layout.js +130 -11
  36. package/dist/transformer-icon.d.ts +16 -0
  37. package/dist/transformer-icon.d.ts.map +1 -0
  38. package/dist/transformer-icon.js +35 -0
  39. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  import 'maplibre-gl/dist/maplibre-gl.css';
3
3
  import { tick } from 'svelte';
4
4
  import type { Layer, PickingInfo } from '@deck.gl/core';
5
- import type { PathLayer, ScatterplotLayer } from '@deck.gl/layers';
5
+ import type { IconLayer, PathLayer, ScatterplotLayer } from '@deck.gl/layers';
6
6
  import type { MapboxOverlay } from '@deck.gl/mapbox';
7
7
  import type { LngLatBoundsLike, Map as MapLibreMap } from 'maplibre-gl';
8
8
  import type { NetworkBranch, NetworkBus } from './api.js';
@@ -26,9 +26,12 @@
26
26
  edgeWidth,
27
27
  isPhaseTerminal,
28
28
  phaseColor,
29
+ transformerMarks,
29
30
  type PlacedMultiBus,
30
- type PlacedMultiEdge
31
+ type PlacedMultiEdge,
32
+ type TransformerMark
31
33
  } from './multiconductor.js';
34
+ import { transformerIcon } from './transformer-icon.js';
32
35
  import { getAppState, getController } from './context.svelte.js';
33
36
  import { displayFmt, fmt, rgbaCss } from './format.js';
34
37
  import type { RGBA } from './colors.js';
@@ -39,6 +42,7 @@
39
42
  onbranchclick,
40
43
  onlocalbranchclick,
41
44
  onmultibusclick,
45
+ onmultiedgeclick,
42
46
  onplacecase,
43
47
  onmapclick
44
48
  }: {
@@ -47,6 +51,7 @@
47
51
  onbranchclick?: (caseId: string, branchId: number) => void;
48
52
  onlocalbranchclick?: (caseId: string, branchId: number) => void;
49
53
  onmultibusclick: (caseId: string, busId: string) => void;
54
+ onmultiedgeclick?: (caseId: string, edgeId: string) => void;
50
55
  onplacecase: (lon: number, lat: number) => void;
51
56
  onmapclick: () => void;
52
57
  } = $props();
@@ -70,6 +75,7 @@
70
75
  type LayerCtors = {
71
76
  PathLayer: typeof PathLayer;
72
77
  ScatterplotLayer: typeof ScatterplotLayer;
78
+ IconLayer: typeof IconLayer;
73
79
  };
74
80
  type CursorState = {
75
81
  isDragging: boolean;
@@ -489,11 +495,10 @@
489
495
  * The branch variant also clears the rating readout on the panel's lower edge. */
490
496
  function framePadding(width: number, height: number, kind: 'case' | 'branch') {
491
497
  if (width <= 760) {
492
- const bottom =
493
- kind === 'branch'
494
- ? Math.min(Math.round(height * 0.42), 300)
495
- : Math.min(Math.round(height * 0.5), 330);
496
- return { top: 130, left: 24, right: 24, bottom };
498
+ // bottom follows the sheet; capped so a fully open sheet still leaves a band to fit into
499
+ const clearance = kind === 'branch' ? 16 : 28;
500
+ const bottom = Math.min(app.sheetInset + clearance, Math.round(height * 0.55));
501
+ return { top: app.headerInset + 8, left: 24, right: 24, bottom };
497
502
  }
498
503
  return kind === 'branch'
499
504
  ? { top: 106, left: 388, right: 76, bottom: 80 }
@@ -546,6 +551,25 @@
546
551
  );
547
552
  }
548
553
 
554
+ function coarsePointer(): boolean {
555
+ return typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
556
+ }
557
+
558
+ // Compact chrome anchoring. maplibre's bottom-right controls sit over the map,
559
+ // so the sheet would bury them; they ride above it instead. Under
560
+ // CHROME_MIN_BAND_PX of map left they drop out rather than climb into the
561
+ // header — at that point the sheet covers what they annotate.
562
+ const CHROME_MIN_BAND_PX = 132;
563
+
564
+ /** How far the sheet can push the chrome up before it would reach the header. */
565
+ const chromeLiftCap = $derived(
566
+ Math.max(0, app.viewportHeight - app.headerInset - CHROME_MIN_BAND_PX)
567
+ );
568
+ const chromeInset = $derived(
569
+ app.compactLayout ? Math.min(app.sheetInset, chromeLiftCap) : 0
570
+ );
571
+ const chromeVisible = $derived(!app.compactLayout || app.sheetInset <= chromeLiftCap);
572
+
549
573
  /** Fly to the selected branch; true when a camera move started. */
550
574
  function focusSelectedBranch(): boolean {
551
575
  if (!map || !selectedBranchCue) return false;
@@ -679,7 +703,8 @@
679
703
  maplibregl: maplibre.default,
680
704
  MapboxOverlay: mapbox.MapboxOverlay,
681
705
  PathLayer: layers.PathLayer,
682
- ScatterplotLayer: layers.ScatterplotLayer
706
+ ScatterplotLayer: layers.ScatterplotLayer,
707
+ IconLayer: layers.IconLayer
683
708
  };
684
709
  }
685
710
 
@@ -692,6 +717,7 @@
692
717
  // deck layers also inherit maplibre's 4096 canvas clamp. The flat positron
693
718
  // basemap writes no depth, so the overlay always draws on top.
694
719
  function buildOverlay(MapboxOverlay: MapModules['MapboxOverlay']): MapboxOverlay {
720
+ const touch = coarsePointer();
695
721
  return new MapboxOverlay({
696
722
  interleaved: true,
697
723
  layers: [],
@@ -699,10 +725,13 @@
699
725
  depthWriteEnabled: false,
700
726
  depthCompare: 'always'
701
727
  },
728
+ // a small bus draws ~9px across, well under a fingertip
729
+ pickingRadius: touch ? 12 : 0,
702
730
  onClick: (info: PickingInfo) => {
703
731
  if (!app.placingId && !isSelectableLayer(info.layer?.id)) onmapclick();
704
732
  },
705
- getTooltip: tooltip,
733
+ // a touch tooltip opens under the finger and stays until the next tap
734
+ getTooltip: touch ? undefined : tooltip,
706
735
  getCursor: ({ isHovering, isDragging }: CursorState) =>
707
736
  app.placingId ? 'crosshair' : isDragging ? 'grabbing' : isHovering ? 'pointer' : 'grab'
708
737
  });
@@ -712,9 +741,9 @@
712
741
  let cleanup = () => {};
713
742
  let cancelled = false;
714
743
  void loadMapModules()
715
- .then(({ maplibregl, MapboxOverlay, PathLayer, ScatterplotLayer }) => {
744
+ .then(({ maplibregl, MapboxOverlay, PathLayer, ScatterplotLayer, IconLayer }) => {
716
745
  if (cancelled) return;
717
- layerCtors = { PathLayer, ScatterplotLayer };
746
+ layerCtors = { PathLayer, ScatterplotLayer, IconLayer };
718
747
  const m = new maplibregl.Map({
719
748
  container,
720
749
  style: STYLE,
@@ -841,7 +870,7 @@
841
870
  // updateTriggers tell deck.gl when accessor outputs changed.
842
871
  $effect(() => {
843
872
  if (!overlay || !layerCtors) return;
844
- const { PathLayer, ScatterplotLayer } = layerCtors;
873
+ const { PathLayer, ScatterplotLayer, IconLayer } = layerCtors;
845
874
  const layers: Layer[] = [];
846
875
  for (const c of app.cases) {
847
876
  if (!c.network) continue;
@@ -1014,13 +1043,17 @@
1014
1043
  for (const c of app.multiCases) {
1015
1044
  if (!c.view) continue;
1016
1045
  const selectedId = c.id === app.activeMultiId ? c.selectedBusId : null;
1046
+ const selectedEdgeId = c.id === app.activeMultiId ? c.selectedEdgeId : null;
1017
1047
  layers.push(
1018
1048
  new PathLayer<PlacedMultiEdge>({
1019
1049
  id: `multi-edges-${c.id}`,
1020
1050
  data: c.view.edges,
1021
1051
  getPath: (e) => e.path,
1022
- getColor: (e) => edgeColor(e.kind, e.closed),
1023
- getWidth: (e) => edgeWidth(e.kind, e.n_phases),
1052
+ // The selected edge draws in the selection blue of the branch cue,
1053
+ // wider, so its panel detail has an obvious source on the map.
1054
+ getColor: (e) =>
1055
+ e.id === selectedEdgeId ? [47, 111, 187, 255] : edgeColor(e.kind, e.closed),
1056
+ getWidth: (e) => edgeWidth(e.kind, e.n_phases) + (e.id === selectedEdgeId ? 2 : 0),
1024
1057
  widthUnits: 'pixels',
1025
1058
  widthMinPixels: 1.2,
1026
1059
  capRounded: true,
@@ -1028,7 +1061,36 @@
1028
1061
  miterLimit: 2,
1029
1062
  pickable: true,
1030
1063
  autoHighlight: true,
1031
- highlightColor: [32, 36, 43, 90]
1064
+ highlightColor: [32, 36, 43, 90],
1065
+ onClick: (info: PickingInfo) => {
1066
+ const e = info.object as PlacedMultiEdge | undefined;
1067
+ if (!e || !onmultiedgeclick) return false;
1068
+ onmultiedgeclick(c.id, e.id);
1069
+ return true;
1070
+ },
1071
+ updateTriggers: {
1072
+ getColor: [selectedEdgeId],
1073
+ getWidth: [selectedEdgeId]
1074
+ }
1075
+ }),
1076
+ // The IEC two-circle symbol at each transformer midpoint, rotated into
1077
+ // the edge bearing. Not pickable: hover and click fall through to the
1078
+ // transformer edge beneath it.
1079
+ new IconLayer<TransformerMark>({
1080
+ id: `multi-xfmr-${c.id}`,
1081
+ data: transformerMarks(c.view.edges),
1082
+ getIcon: () => transformerIcon(),
1083
+ getPosition: (m) => m.position,
1084
+ getAngle: (m) => m.angle,
1085
+ billboard: false,
1086
+ getSize: 16,
1087
+ sizeUnits: 'pixels',
1088
+ getColor: (m) =>
1089
+ m.id === selectedEdgeId ? [47, 111, 187, 255] : edgeColor('transformer', true),
1090
+ pickable: false,
1091
+ updateTriggers: {
1092
+ getColor: [selectedEdgeId]
1093
+ }
1032
1094
  }),
1033
1095
  new ScatterplotLayer<PlacedMultiBus>({
1034
1096
  id: `multi-buses-${c.id}`,
@@ -1081,6 +1143,25 @@
1081
1143
  void tick().then(scheduleCueSync);
1082
1144
  });
1083
1145
 
1146
+ // Pan the selected bus into the band the sheet leaves. The delay outlasts the
1147
+ // sheet's snap transition; sheetInset is read inside the timeout so the effect
1148
+ // does not re-run (and re-pan) on every frame of a sheet drag.
1149
+ $effect(() => {
1150
+ const cue = selectedBusCue;
1151
+ if (!map || !cue || !app.compactLayout) return;
1152
+ void cue.key;
1153
+ const m = map;
1154
+ const timer = setTimeout(() => {
1155
+ const { clientHeight } = m.getContainer();
1156
+ const y = m.project([cue.lon, cue.lat]).y;
1157
+ const floor = clientHeight - app.sheetInset - CUE_MARGIN_PX;
1158
+ const ceiling = 104;
1159
+ const dy = y > floor ? y - floor : y < ceiling ? y - ceiling : 0;
1160
+ if (dy !== 0) m.panBy([0, dy], { duration: prefersReducedMotion() ? 0 : 320 });
1161
+ }, 340);
1162
+ return () => clearTimeout(timer);
1163
+ });
1164
+
1084
1165
  function boundsFor(target: string): LngLatBoundsLike | null {
1085
1166
  const points: [number, number][] = [];
1086
1167
  const fold = (pts: { lon: number; lat: number }[]) => {
@@ -1136,7 +1217,11 @@
1136
1217
  </script>
1137
1218
 
1138
1219
  {#key mapGen}
1139
- <div class="map-stage">
1220
+ <div
1221
+ class="map-stage"
1222
+ class:chrome-clear={!chromeVisible}
1223
+ style="--chrome-inset: {chromeInset}px"
1224
+ >
1140
1225
  <div class="map" {@attach initMap}></div>
1141
1226
  {#if selectedBusCue}
1142
1227
  {#key selectedBusCue.key}
@@ -1163,6 +1248,7 @@
1163
1248
  <button
1164
1249
  type="button"
1165
1250
  class="place-at-center mono"
1251
+ style="--chrome-inset: {chromeInset}px"
1166
1252
  onclick={() => {
1167
1253
  if (!map) return;
1168
1254
  const center = map.getCenter();
@@ -1346,7 +1432,7 @@
1346
1432
  .place-at-center {
1347
1433
  position: absolute;
1348
1434
  left: 50%;
1349
- bottom: 92px;
1435
+ bottom: calc(92px + var(--chrome-inset, 0px));
1350
1436
  z-index: 15;
1351
1437
  transform: translateX(-50%);
1352
1438
  padding: 7px 14px;
@@ -1361,17 +1447,27 @@
1361
1447
 
1362
1448
  @media (max-width: 420px) {
1363
1449
  .place-at-center {
1364
- bottom: 78px;
1450
+ bottom: calc(78px + var(--chrome-inset, 0px));
1365
1451
  }
1366
1452
  }
1367
1453
 
1368
1454
  .map :global(.maplibregl-ctrl-bottom-right) {
1369
1455
  right: 20px;
1370
- bottom: 18px;
1456
+ /* rides above the bottom sheet so attribution stays legible */
1457
+ bottom: calc(18px + var(--chrome-inset, 0px));
1371
1458
  display: flex;
1372
1459
  flex-direction: column;
1373
1460
  align-items: flex-end;
1374
1461
  gap: 6px;
1462
+ transition:
1463
+ bottom var(--dur-med) var(--ease-out),
1464
+ opacity var(--dur-fast) var(--ease-out);
1465
+ }
1466
+
1467
+ /* The sheet has taken the map; nothing left down there to attribute. */
1468
+ .map-stage.chrome-clear :global(.maplibregl-ctrl-bottom-right) {
1469
+ opacity: 0;
1470
+ pointer-events: none;
1375
1471
  }
1376
1472
 
1377
1473
  .map :global(.maplibregl-ctrl-bottom-right .maplibregl-ctrl) {
@@ -1399,6 +1495,26 @@
1399
1495
  color: var(--text-secondary);
1400
1496
  }
1401
1497
 
1498
+ @media (hover: none), (pointer: coarse) {
1499
+ /* A long press on the canvas must not raise the iOS callout or magnifier.
1500
+ Scoped to touch so a mouse can still select the attribution text. */
1501
+ .map {
1502
+ -webkit-touch-callout: none;
1503
+ -webkit-tap-highlight-color: transparent;
1504
+ user-select: none;
1505
+ }
1506
+
1507
+ /* maplibre ships 29px zoom buttons; a fingertip wants 44. */
1508
+ .map :global(.maplibregl-ctrl-group button) {
1509
+ width: 44px;
1510
+ height: 44px;
1511
+ }
1512
+
1513
+ .map :global(.maplibregl-ctrl-group button .maplibregl-ctrl-icon) {
1514
+ background-size: 22px 22px;
1515
+ }
1516
+ }
1517
+
1402
1518
  .map :global(.deck-tooltip) {
1403
1519
  background: var(--panel) !important;
1404
1520
  border: 1px solid var(--line);
@@ -1413,11 +1529,31 @@
1413
1529
 
1414
1530
  @media (max-width: 760px) {
1415
1531
  .map :global(.maplibregl-ctrl-attrib) {
1532
+ /* last in the column so it rests on the sheet's edge instead of
1533
+ floating over the network a zoom control's height above it */
1534
+ order: 1;
1416
1535
  width: auto;
1417
1536
  min-width: 0;
1418
1537
  right: 0;
1419
1538
  white-space: normal;
1420
1539
  }
1540
+
1541
+ /* The band of map a phone has left between the header and the sheet is
1542
+ about the height of the solve card, which covers the buttons wherever
1543
+ they land. Pinch and double tap zoom; the attribution stays. */
1544
+ .map :global(.maplibregl-ctrl-bottom-right .maplibregl-ctrl-group) {
1545
+ display: none;
1546
+ }
1547
+ }
1548
+
1549
+ /* One line on a small phone: wrapped attribution turns into a wide block
1550
+ sitting over the network. */
1551
+ @media (max-width: 460px) {
1552
+ .map :global(.maplibregl-ctrl-attrib) {
1553
+ max-width: calc(100vw - 20px);
1554
+ font-size: 9px;
1555
+ white-space: nowrap;
1556
+ }
1421
1557
  }
1422
1558
 
1423
1559
  @media (prefers-reduced-motion: reduce) {
@@ -5,6 +5,7 @@ type $$ComponentProps = {
5
5
  onbranchclick?: (caseId: string, branchId: number) => void;
6
6
  onlocalbranchclick?: (caseId: string, branchId: number) => void;
7
7
  onmultibusclick: (caseId: string, busId: string) => void;
8
+ onmultiedgeclick?: (caseId: string, edgeId: string) => void;
8
9
  onplacecase: (lon: number, lat: number) => void;
9
10
  onmapclick: () => void;
10
11
  };
@@ -1 +1 @@
1
- {"version":3,"file":"TellegenMap.svelte.d.ts","sourceRoot":"","sources":["../src/lib/TellegenMap.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,kCAAkC,CAAC;AAgEzC,KAAK,gBAAgB,GAAI;IACxB,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3D,kBAAkB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAChE,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChD,UAAU,EAAE,MAAM,IAAI,CAAC;CACvB,CAAC;AAkmCH,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"TellegenMap.svelte.d.ts","sourceRoot":"","sources":["../src/lib/TellegenMap.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,kCAAkC,CAAC;AAoEzC,KAAK,gBAAgB,GAAI;IACxB,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3D,kBAAkB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAChE,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5D,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChD,UAAU,EAAE,MAAM,IAAI,CAAC;CACvB,CAAC;AA+qCH,QAAA,MAAM,WAAW,sDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -12,6 +12,7 @@
12
12
  import TellegenMap from './TellegenMap.svelte';
13
13
 
14
14
  const FILE_DROP_QUERY = '(hover: hover) and (pointer: fine) and (min-width: 761px)';
15
+ const COMPACT_QUERY = '(max-width: 760px)';
15
16
 
16
17
  const app = getAppState();
17
18
  const ctrl = getController();
@@ -50,7 +51,28 @@
50
51
  };
51
52
  syncFileDropUi();
52
53
  query.addEventListener('change', syncFileDropUi);
53
- return () => query.removeEventListener('change', syncFileDropUi);
54
+
55
+ const compact = window.matchMedia(COMPACT_QUERY);
56
+ const syncCompact = () => {
57
+ app.compactLayout = compact.matches;
58
+ if (!compact.matches) app.sheetInset = 0;
59
+ };
60
+ syncCompact();
61
+ compact.addEventListener('change', syncCompact);
62
+
63
+ // The sheet sizes its snaps against the viewport and the map caps how far
64
+ // it lifts the basemap chrome, so both read one measurement taken here.
65
+ const syncViewport = () => (app.viewportHeight = window.innerHeight);
66
+ syncViewport();
67
+ window.addEventListener('resize', syncViewport);
68
+ window.addEventListener('orientationchange', syncViewport);
69
+
70
+ return () => {
71
+ query.removeEventListener('change', syncFileDropUi);
72
+ compact.removeEventListener('change', syncCompact);
73
+ window.removeEventListener('resize', syncViewport);
74
+ window.removeEventListener('orientationchange', syncViewport);
75
+ };
54
76
  });
55
77
 
56
78
  function dragHasFiles(e: DragEvent): boolean {
@@ -101,6 +123,7 @@
101
123
  onbranchclick={ctrl.selectBranch}
102
124
  onlocalbranchclick={ctrl.selectLocalBranch}
103
125
  onmultibusclick={ctrl.selectMultiBus}
126
+ onmultiedgeclick={ctrl.selectMultiEdge}
104
127
  onplacecase={ctrl.placeCase}
105
128
  onmapclick={ctrl.clearSelection}
106
129
  />
@@ -108,11 +131,14 @@
108
131
  <AppHeader />
109
132
  <ControlPanel />
110
133
  <SolveCard />
111
- <BusPicker />
134
+ <!-- ControlPanel mounts the lookup and the footer inline when compact. -->
135
+ {#if !app.compactLayout}
136
+ <BusPicker />
137
+ {/if}
112
138
  <DropZone />
113
139
  <PlacementCue />
114
140
  <RestoreDefaultsButton />
115
- {#if config.showFooter}
141
+ {#if config.showFooter && !app.compactLayout}
116
142
  <AppFooter />
117
143
  {/if}
118
144
  </main>
@@ -1 +1 @@
1
- {"version":3,"file":"TellegenShell.svelte.d.ts","sourceRoot":"","sources":["../src/lib/TellegenShell.svelte.ts"],"names":[],"mappings":"AA4HA,QAAA,MAAM,aAAa,2DAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"TellegenShell.svelte.d.ts","sourceRoot":"","sources":["../src/lib/TellegenShell.svelte.ts"],"names":[],"mappings":"AAqJA,QAAA,MAAM,aAAa,2DAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -2,9 +2,12 @@
2
2
  import { getController } from '../context.svelte.js';
3
3
 
4
4
  const ctrl = getController();
5
+
6
+ /** Render in the parent's flow instead of pinned to the bottom of the map. */
7
+ let { inline = false }: { inline?: boolean } = $props();
5
8
  </script>
6
9
 
7
- <footer class="mono">
10
+ <footer class="mono" class:inline>
8
11
  <a href="/credits/">credits</a>
9
12
  <i class="sep"></i>
10
13
  <a href="/privacy/">privacy</a>
@@ -50,6 +53,28 @@
50
53
  color: var(--text-accent);
51
54
  }
52
55
 
56
+ footer.inline {
57
+ position: static;
58
+ margin-top: 14px;
59
+ padding: 0;
60
+ background: none;
61
+ animation: none;
62
+ pointer-events: auto;
63
+ }
64
+
65
+ @media (hover: none), (pointer: coarse) {
66
+ footer a {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ min-height: 44px;
70
+ }
71
+
72
+ footer.inline {
73
+ margin-top: 4px;
74
+ font-size: 11px;
75
+ }
76
+ }
77
+
53
78
  @media (max-width: 760px) {
54
79
  footer {
55
80
  padding: 7px 10px;
@@ -1,19 +1,7 @@
1
- interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
- new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
- $$bindings?: Bindings;
4
- } & Exports;
5
- (internal: unknown, props: {
6
- $$events?: Events;
7
- $$slots?: Slots;
8
- }): Exports & {
9
- $set?: any;
10
- $on?: any;
11
- };
12
- z_$$bindings?: Bindings;
13
- }
14
- declare const AppFooter: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
- [evt: string]: CustomEvent<any>;
16
- }, {}, {}, string>;
17
- type AppFooter = InstanceType<typeof AppFooter>;
1
+ type $$ComponentProps = {
2
+ inline?: boolean;
3
+ };
4
+ declare const AppFooter: import("svelte").Component<$$ComponentProps, {}, "">;
5
+ type AppFooter = ReturnType<typeof AppFooter>;
18
6
  export default AppFooter;
19
7
  //# sourceMappingURL=AppFooter.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"AppFooter.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AppFooter.svelte.ts"],"names":[],"mappings":"AAyBA,UAAU,kCAAkC,CAAC,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,MAAM,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,KAAK,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,GAAG,EAAE,OAAO,GAAG,EAAE,EAAE,QAAQ,GAAG,MAAM;IACpM,KAAK,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,KAAK,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC;IACjK,CAAC,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAA;KAAC,GAAG,OAAO,GAAG;QAAE,IAAI,CAAC,EAAE,GAAG,CAAC;QAAC,GAAG,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC;IACtG,YAAY,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAKD,QAAA,MAAM,SAAS;;kBAA+E,CAAC;AAC7E,KAAK,SAAS,GAAG,YAAY,CAAC,OAAO,SAAS,CAAC,CAAC;AAClD,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"AppFooter.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AppFooter.svelte.ts"],"names":[],"mappings":"AAKC,KAAK,gBAAgB,GAAI;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAwB/C,QAAA,MAAM,SAAS,sDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -7,9 +7,23 @@
7
7
  const config = getUiConfig();
8
8
 
9
9
  let fileInput = $state.raw<HTMLInputElement | undefined>(undefined);
10
+ let headerEl = $state.raw<HTMLElement | undefined>(undefined);
11
+
12
+ // The case tabs wrap onto their own row on a narrow viewport and unwrap on a
13
+ // short one, so the height moves with both the breakpoint and the case list.
14
+ // Publish it for the map chrome and the sheet, which both anchor below it.
15
+ $effect(() => {
16
+ const el = headerEl;
17
+ if (!el) return;
18
+ const measure = () => (app.headerInset = Math.round(el.getBoundingClientRect().height));
19
+ measure();
20
+ const observer = new ResizeObserver(measure);
21
+ observer.observe(el);
22
+ return () => observer.disconnect();
23
+ });
10
24
  </script>
11
25
 
12
- <header>
26
+ <header bind:this={headerEl}>
13
27
  <div class="brand">
14
28
  <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
15
29
  <path d="M4 18 L12 6 L20 18" stroke="#b25e00" stroke-width="1.6" fill="none" />
@@ -397,6 +411,50 @@
397
411
  }
398
412
  }
399
413
 
414
+ /* Landscape phone: height is the scarce axis. Keep the case tabs on the
415
+ brand's row instead of giving them one of their own, which halves the
416
+ header and leaves the map a band worth looking at. */
417
+ @media (max-width: 760px) and (max-height: 520px) {
418
+ header {
419
+ flex-wrap: nowrap;
420
+ align-items: center;
421
+ padding: 6px 10px;
422
+ }
423
+
424
+ h1 {
425
+ font-size: 17px;
426
+ }
427
+
428
+ .cases {
429
+ order: 0;
430
+ width: auto;
431
+ justify-content: flex-start;
432
+ }
433
+
434
+ .kicker {
435
+ display: none;
436
+ }
437
+ }
438
+
439
+ /* Touch sizing keys off the pointer, not the breakpoint: a tablet gets the
440
+ wide layout and still needs a fingertip's worth of chip. */
441
+ @media (hover: none), (pointer: coarse) {
442
+ .cases > button,
443
+ .case-chip {
444
+ min-height: 44px;
445
+ }
446
+
447
+ .case-activate {
448
+ min-height: 44px;
449
+ }
450
+
451
+ .kicker a {
452
+ display: inline-flex;
453
+ align-items: center;
454
+ min-height: 44px;
455
+ }
456
+ }
457
+
400
458
  @media (prefers-reduced-motion: reduce) {
401
459
  header {
402
460
  animation: none;
@@ -1 +1 @@
1
- {"version":3,"file":"AppHeader.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AppHeader.svelte.ts"],"names":[],"mappings":"AA+EA,QAAA,MAAM,SAAS,2DAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"AppHeader.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/AppHeader.svelte.ts"],"names":[],"mappings":"AA6FA,QAAA,MAAM,SAAS,2DAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -98,4 +98,17 @@
98
98
  box-shadow: inset 3px 0 0 var(--accent);
99
99
  font-weight: 600;
100
100
  }
101
+
102
+ /* Each row is a tap target that reframes the map, so give it a finger's worth
103
+ of height and show a couple more rows before the list scrolls. */
104
+ @media (hover: none), (pointer: coarse) {
105
+ ul {
106
+ max-height: 176px;
107
+ }
108
+
109
+ li button {
110
+ min-height: 44px;
111
+ padding-block: 6px;
112
+ }
113
+ }
101
114
  </style>
@@ -5,6 +5,9 @@
5
5
  const app = getAppState();
6
6
  const ctrl = getController();
7
7
 
8
+ /** Render in the parent's flow instead of as a card floating over the map. */
9
+ let { inline = false }: { inline?: boolean } = $props();
10
+
8
11
  const buses = $derived(app.active?.network?.buses ?? app.activeLocal?.view?.buses ?? []);
9
12
 
10
13
  let query = $state('');
@@ -27,6 +30,11 @@
27
30
  query = '';
28
31
  open = false;
29
32
  highlighted = 0;
33
+ // Drop the on-screen keyboard, which otherwise covers the readout the
34
+ // selection just produced. A keyboard user keeps focus for the next lookup.
35
+ if (typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches) {
36
+ inputEl?.blur();
37
+ }
30
38
  }
31
39
 
32
40
  function onKeydown(e: KeyboardEvent) {
@@ -69,6 +77,7 @@
69
77
  {#if buses.length > 0}
70
78
  <div
71
79
  class="bus-lookup"
80
+ class:inline
72
81
  title="Press / to focus bus lookup"
73
82
  onfocusout={(e) => {
74
83
  if (!e.currentTarget.contains(e.relatedTarget as Node | null)) open = false;
@@ -160,6 +169,37 @@
160
169
  outline-offset: 1px;
161
170
  }
162
171
 
172
+ /* Safari zooms the layout viewport when a focused input is under 16px. */
173
+ @media (hover: none), (pointer: coarse) {
174
+ input {
175
+ min-height: 44px;
176
+ font-size: 16px;
177
+ padding: 7px 10px;
178
+ }
179
+
180
+ li {
181
+ display: flex;
182
+ align-items: center;
183
+ min-height: 44px;
184
+ font-size: 14px;
185
+ }
186
+ }
187
+
188
+ .bus-lookup.inline {
189
+ /* relative, not static: the results list is absolute against this block */
190
+ position: relative;
191
+ inset: auto;
192
+ width: auto;
193
+ margin-top: 12px;
194
+ padding: 0;
195
+ background: none;
196
+ border: 0;
197
+ border-radius: 0;
198
+ backdrop-filter: none;
199
+ box-shadow: none;
200
+ animation: none;
201
+ }
202
+
163
203
  ul {
164
204
  position: absolute;
165
205
  z-index: 20;
@@ -1,4 +1,7 @@
1
- declare const BusPicker: import("svelte").Component<Record<string, never>, {}, "">;
1
+ type $$ComponentProps = {
2
+ inline?: boolean;
3
+ };
4
+ declare const BusPicker: import("svelte").Component<$$ComponentProps, {}, "">;
2
5
  type BusPicker = ReturnType<typeof BusPicker>;
3
6
  export default BusPicker;
4
7
  //# sourceMappingURL=BusPicker.svelte.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"BusPicker.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/BusPicker.svelte.ts"],"names":[],"mappings":"AA0GA,QAAA,MAAM,SAAS,2DAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"BusPicker.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/BusPicker.svelte.ts"],"names":[],"mappings":"AAMC,KAAK,gBAAgB,GAAI;IAAE,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AA6G/C,QAAA,MAAM,SAAS,sDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}