@signalk/tracks-plugin 3.0.0-beta.0 → 3.0.0-beta.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.
package/app-icon.svg ADDED
@@ -0,0 +1,42 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Tracks">
2
+ <defs>
3
+ <linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0" stop-color="#1b6ea8"/>
5
+ <stop offset="1" stop-color="#0b3a5e"/>
6
+ </linearGradient>
7
+ <clipPath id="card">
8
+ <rect x="0" y="0" width="128" height="128" rx="22"/>
9
+ </clipPath>
10
+ </defs>
11
+
12
+ <g clip-path="url(#card)">
13
+ <rect width="128" height="128" fill="url(#sea)"/>
14
+
15
+ <!-- Chart grid, as on the sibling plugins' icons. -->
16
+ <g stroke="#bee3f4" stroke-opacity="0.28" stroke-width="1" fill="none">
17
+ <path d="M0 32 H128 M0 64 H128 M0 96 H128"/>
18
+ <path d="M32 0 V128 M64 0 V128 M96 0 V128"/>
19
+ </g>
20
+
21
+ <!-- The track itself: the recorded path, drawn through its own points. -->
22
+ <path d="M24 102 C 40 94, 42 72, 58 64 S 84 58, 94 44"
23
+ fill="none" stroke="#0b3a5e" stroke-opacity="0.45"
24
+ stroke-width="9" stroke-linecap="round"/>
25
+ <path d="M24 102 C 40 94, 42 72, 58 64 S 84 58, 94 44"
26
+ fill="none" stroke="#ffd76a" stroke-width="5"
27
+ stroke-linecap="round" stroke-linejoin="round"/>
28
+
29
+ <!-- Position fixes. The track is a series of recorded points, not a
30
+ drawn line, and the icon should read that way at 32px too. -->
31
+ <g fill="#fffaf0" stroke="#0b3a5e" stroke-opacity="0.5" stroke-width="1.5">
32
+ <circle cx="24" cy="102" r="4.5"/>
33
+ <circle cx="49" cy="74" r="4"/>
34
+ <circle cx="72" cy="61" r="4"/>
35
+ </g>
36
+
37
+ <!-- The vessel, at the newest fix, heading along the track. -->
38
+ <path d="M94 34 L102 52 L94 47 L86 52 Z" fill="#fffaf0"
39
+ stroke="#0b3a5e" stroke-opacity="0.5" stroke-width="1.5"
40
+ stroke-linejoin="round"/>
41
+ </g>
42
+ </svg>
package/dist/index.js CHANGED
@@ -161,6 +161,42 @@ function thin(points, resolution) {
161
161
  return result;
162
162
  }
163
163
  /**
164
+ * Thin to at most `maxPoints`, widening the spacing until the budget fits.
165
+ *
166
+ * A budget, not a fidelity contract: the caller has said how many points it can
167
+ * afford to transfer and draw, whatever shape the track is. Returns the
168
+ * resolution actually applied so the response can report it — a client that is
169
+ * given 29 points instead of the 546 it would otherwise have received should be
170
+ * able to see the spacing that produced them.
171
+ *
172
+ * Widening by doubling from the span-derived estimate rather than binary
173
+ * searching: `thin` keeps the first point and the last unconditionally, so the
174
+ * count is not a clean function of the spacing and an exact solve would be
175
+ * false precision. Two or three passes settle it in practice.
176
+ */
177
+ function thinToBudget(points, maxPoints, resolution) {
178
+ const start = thin(points, resolution);
179
+ if (start.length <= maxPoints) return {
180
+ points: start,
181
+ resolution
182
+ };
183
+ if (maxPoints < 2) return {
184
+ points: start.slice(0, Math.max(0, maxPoints)),
185
+ resolution
186
+ };
187
+ const span = start[start.length - 1].timestamp - start[0].timestamp;
188
+ let spacing = Math.max(1, Math.ceil(span / (maxPoints - 1)), resolution ?? 0);
189
+ let result = thin(points, spacing);
190
+ while (result.length > maxPoints && spacing <= span) {
191
+ spacing *= 2;
192
+ result = thin(points, spacing);
193
+ }
194
+ return {
195
+ points: result,
196
+ resolution: spacing
197
+ };
198
+ }
199
+ /**
164
200
  * Split points into segments wherever recording stopped for longer than `gap`.
165
201
  *
166
202
  * A track is not one continuous line: a vessel that stops for the night, or
@@ -313,7 +349,7 @@ function resolveContext(vesselId, selfContext) {
313
349
  function createMatcher(params, selfPosition, debug) {
314
350
  if (params.bbox) {
315
351
  const inBounds = createInBounds(params.bbox);
316
- return (track) => inBounds(lastPoint(track));
352
+ return params.intersects ? (track) => track.some(inBounds) : (track) => inBounds(lastPoint(track));
317
353
  } else if (params.radius !== null) {
318
354
  if (!selfPosition) throw new Error("No self position to calculate radius values");
319
355
  const radius = params.radius;
@@ -511,21 +547,33 @@ var totalMilliseconds = (duration) => duration.total({
511
547
  unit: "milliseconds",
512
548
  relativeTo: CALENDAR_REFERENCE
513
549
  });
550
+ /**
551
+ * Milliseconds back to a Duration, for reporting the spacing applied.
552
+ *
553
+ * Exact, not rounded to something tidier. A budget-derived spacing lands on
554
+ * values like 24470ms, and `PT24.47S` does read as false precision — but the
555
+ * field says what was applied, and a client re-querying with a rounded `PT24S`
556
+ * gets 51 points where the budget it asked for was 50. Reproducibility beats
557
+ * tidiness.
558
+ */
559
+ var msToDuration = (ms) => Temporal.Duration.from({
560
+ milliseconds: Math.trunc(ms),
561
+ nanoseconds: Math.round((ms - Math.trunc(ms)) * 1e6)
562
+ }).round({ largestUnit: "hour" });
514
563
  var toLngLat$1 = ([lat, lng]) => [lng, lat];
515
564
  /** Bounding box of the returned geometry, in GeoJSON order. */
516
565
  var boundsOf = (points) => {
517
566
  if (points.length === 0) return;
518
- let west = points[0].position[1];
519
- let east = west;
520
567
  let south = points[0].position[0];
521
568
  let north = south;
569
+ const longitudes = [];
522
570
  for (const { position } of points) {
523
571
  const [lat, lng] = position;
524
- if (lng < west) west = lng;
525
- if (lng > east) east = lng;
526
572
  if (lat < south) south = lat;
527
573
  if (lat > north) north = lat;
574
+ longitudes.push(lng);
528
575
  }
576
+ const [west, east] = longitudeSpan(longitudes);
529
577
  return [
530
578
  west,
531
579
  south,
@@ -533,6 +581,36 @@ var boundsOf = (points) => {
533
581
  north
534
582
  ];
535
583
  };
584
+ /**
585
+ * The narrower of the two longitude intervals covering these points.
586
+ *
587
+ * A plain min/max describes a track spanning the antimeridian as almost the
588
+ * whole globe: two fixes at 179 and -179 are two degrees apart, but min/max
589
+ * reports 358. RFC 7946 says a box crossing the antimeridian is written with
590
+ * west greater than east, so the wrap-around interval is expressible — pick it
591
+ * whenever it is the shorter of the two.
592
+ *
593
+ * The widest gap between adjacent longitudes is the part of the globe the track
594
+ * does not cover, so the complement of that gap is the tightest interval that
595
+ * does.
596
+ */
597
+ var longitudeSpan = (longitudes) => {
598
+ const sorted = [...longitudes].sort((a, b) => a - b);
599
+ const min = sorted[0];
600
+ const max = sorted[sorted.length - 1];
601
+ let widestGap = 360 - (max - min);
602
+ let west = min;
603
+ let east = max;
604
+ for (let i = 1; i < sorted.length; i++) {
605
+ const gap = sorted[i] - sorted[i - 1];
606
+ if (gap > widestGap) {
607
+ widestGap = gap;
608
+ west = sorted[i];
609
+ east = sorted[i - 1];
610
+ }
611
+ }
612
+ return [west, east];
613
+ };
536
614
  function createTrackProvider(deps) {
537
615
  const matching = async (query) => {
538
616
  const store = deps.store();
@@ -542,7 +620,8 @@ function createTrackProvider(deps) {
542
620
  const wanted = query.contexts?.length ? new Set(query.contexts.map(resolveSelf(deps.selfContext()))) : void 0;
543
621
  const collection = await store.getFilteredTimedTracks({
544
622
  bbox: toGeoBounds(query.bbox),
545
- radius: null
623
+ radius: null,
624
+ intersects: true
546
625
  }, void 0, void 0, {
547
626
  ...window ? { window } : {},
548
627
  ...resolution === void 0 ? {} : { resolution }
@@ -560,8 +639,14 @@ function createTrackProvider(deps) {
560
639
  const matched = await matching(query);
561
640
  const gap = deps.segmentGap();
562
641
  const selfContext = deps.selfContext();
642
+ const requested = query.resolution ? totalMilliseconds(query.resolution) : void 0;
563
643
  const features = [];
564
- for (const [context, points] of matched) {
644
+ for (const [context, all] of matched) {
645
+ const { points, resolution: appliedMs } = query.maxPoints === void 0 ? {
646
+ points: all,
647
+ resolution: requested
648
+ } : thinToBudget(all, query.maxPoints, requested);
649
+ if (points.length === 0) continue;
565
650
  const segments = segment(points, gap);
566
651
  const bbox = boundsOf(points);
567
652
  features.push({
@@ -577,7 +662,7 @@ function createTrackProvider(deps) {
577
662
  to: new Date(points[points.length - 1].timestamp).toISOString(),
578
663
  ...bbox ? { bbox } : {},
579
664
  pointCount: points.length,
580
- ...query.resolution ? { resolution: query.resolution.toString() } : {},
665
+ ...appliedMs === void 0 ? {} : { resolution: msToDuration(appliedMs).toString() },
581
666
  ...query.times ? { coordTimes: segments.map(toIsoTimes) } : {}
582
667
  }
583
668
  });