tangram-ship162 0.2.1__cp314-cp314-musllinux_1_1_aarch64.whl

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.
@@ -0,0 +1,50 @@
1
+ <template>
2
+ <div class="ship-count-widget">
3
+ <div class="count-display">
4
+ <span id="visible_ships_count">{{ visibleCount }}</span> (<span>{{
5
+ totalCount ?? 0
6
+ }}</span
7
+ >)
8
+ </div>
9
+ <span id="count_ships">visible ships</span>
10
+ </div>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ import { computed, inject } from "vue";
15
+ import type { TangramApi } from "@open-aviation/tangram-core/api";
16
+
17
+ const tangramApi = inject<TangramApi>("tangramApi");
18
+ if (!tangramApi) {
19
+ throw new Error("assert: tangram api not provided");
20
+ }
21
+
22
+ const totalCount = computed(
23
+ () => tangramApi.state.totalCounts.value?.get("ship162_ship") ?? 0
24
+ );
25
+ const visibleCount = computed(
26
+ () => tangramApi.state.getEntitiesByType("ship162_ship").value?.size ?? 0
27
+ );
28
+ </script>
29
+
30
+ <style scoped>
31
+ #visible_ships_count {
32
+ font-size: 1em;
33
+ color: #4c78a8;
34
+ }
35
+ #count_ships {
36
+ color: #79706e;
37
+ font-size: 9pt;
38
+ text-align: center;
39
+ }
40
+ .ship-count-widget {
41
+ display: flex;
42
+ flex-direction: column;
43
+ align-items: center;
44
+ padding: 1px;
45
+ border-bottom: 1px solid #ddd;
46
+ }
47
+ .count-display {
48
+ text-align: center;
49
+ }
50
+ </style>
@@ -0,0 +1,277 @@
1
+ <template>
2
+ <div class="ship-list">
3
+ <div
4
+ v-for="item in shipList"
5
+ :key="item.id"
6
+ class="list-item"
7
+ :class="{ expanded: isExpanded(item.id) }"
8
+ >
9
+ <div class="header" @click="toggleExpand(item.id)">
10
+ <div class="row main-row">
11
+ <div class="left-group">
12
+ <span v-if="item.state.ship_name" class="ship-name">{{
13
+ item.state.ship_name
14
+ }}</span>
15
+ <span v-else class="ship-name no-data">[no name]</span>
16
+ <span v-if="item.state.ship_type" class="chip blue">{{
17
+ item.state.ship_type
18
+ }}</span>
19
+ <span class="chip yellow">{{ item.state.mmsi }}</span>
20
+ </div>
21
+ <div class="right-group">
22
+ <span v-if="item.state.speed !== undefined"
23
+ >{{ item.state.speed.toFixed(1) }} kts</span
24
+ >
25
+ <span
26
+ v-if="item.state.speed !== undefined && item.state.course !== undefined"
27
+ class="sep"
28
+ >·</span
29
+ >
30
+ <span v-if="item.state.course !== undefined"
31
+ >{{ item.state.course.toFixed(0) }}°</span
32
+ >
33
+ </div>
34
+ </div>
35
+ <div class="row sub-row">
36
+ <div class="left-group destination">
37
+ {{ item.state.destination || "No destination" }}
38
+ </div>
39
+ <div class="right-group"></div>
40
+ </div>
41
+ </div>
42
+
43
+ <div v-if="isExpanded(item.id)" class="details-body" @click.stop>
44
+ <div class="details-header">
45
+ <span v-if="item.state.mmsi_info?.flag">
46
+ {{ item.state.mmsi_info.flag }}
47
+ {{ item.state.mmsi_info.country }}
48
+ </span>
49
+ <span v-if="item.state.status" class="status-chip">{{
50
+ item.state.status
51
+ }}</span>
52
+ </div>
53
+
54
+ <table class="details-table">
55
+ <tr v-if="item.state.callsign">
56
+ <td class="label">Callsign:</td>
57
+ <td>{{ item.state.callsign }}</td>
58
+ </tr>
59
+ <tr v-if="item.state.imo">
60
+ <td class="label">IMO:</td>
61
+ <td>{{ item.state.imo }}</td>
62
+ </tr>
63
+ <tr v-if="getDimensions(item.state)">
64
+ <td class="label">Dimensions:</td>
65
+ <td>{{ getDimensions(item.state) }} m</td>
66
+ </tr>
67
+ <tr v-if="item.state.draught">
68
+ <td class="label">Draught:</td>
69
+ <td>{{ item.state.draught }} m</td>
70
+ </tr>
71
+ </table>
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </template>
76
+
77
+ <script setup lang="ts">
78
+ import { computed, inject, reactive, watch } from "vue";
79
+ import type { TangramApi } from "@open-aviation/tangram-core/api";
80
+ import type { Ship162Vessel } from ".";
81
+
82
+ const tangramApi = inject<TangramApi>("tangramApi");
83
+ if (!tangramApi) {
84
+ throw new Error("assert: tangram api not provided");
85
+ }
86
+
87
+ const expandedIds = reactive(new Set<string>());
88
+
89
+ const shipList = computed(() => {
90
+ const list = [];
91
+ for (const [id, entity] of tangramApi.state.activeEntities.value) {
92
+ if (entity.type === "ship162_ship") {
93
+ list.push({ id, state: entity.state as Ship162Vessel });
94
+ }
95
+ }
96
+ return list.sort((a, b) => a.id.localeCompare(b.id));
97
+ });
98
+
99
+ const isExpanded = (id: string) => {
100
+ return shipList.value.length === 1 || expandedIds.has(id);
101
+ };
102
+
103
+ const toggleExpand = (id: string) => {
104
+ if (shipList.value.length === 1) return;
105
+ if (expandedIds.has(id)) {
106
+ expandedIds.delete(id);
107
+ } else {
108
+ expandedIds.add(id);
109
+ }
110
+ };
111
+
112
+ const getDimensions = (state: Ship162Vessel) => {
113
+ const { to_bow, to_stern, to_port, to_starboard } = state;
114
+ if (to_bow != null && to_stern != null && to_port != null && to_starboard != null) {
115
+ const length = to_bow + to_stern;
116
+ const width = to_port + to_starboard;
117
+ if (length > 0 || width > 0) {
118
+ return `${length} x ${width}`;
119
+ }
120
+ }
121
+ return null;
122
+ };
123
+
124
+ watch(
125
+ () => shipList.value.length,
126
+ (newLen, oldLen) => {
127
+ if (oldLen === 1 && newLen === 2) {
128
+ expandedIds.clear();
129
+ }
130
+ }
131
+ );
132
+ </script>
133
+
134
+ <style scoped>
135
+ .ship-list {
136
+ display: flex;
137
+ flex-direction: column;
138
+ max-height: calc(100vh - 150px);
139
+ overflow-y: auto;
140
+ }
141
+
142
+ .list-item {
143
+ border-bottom: 1px solid #eee;
144
+ cursor: pointer;
145
+ background-color: white;
146
+ }
147
+
148
+ .list-item:hover .header {
149
+ background-color: #f5f5f5;
150
+ }
151
+
152
+ .header {
153
+ padding: 4px 8px;
154
+ }
155
+
156
+ .expanded .header {
157
+ border-bottom: 1px solid #eee;
158
+ background-color: #f0f7ff;
159
+ }
160
+
161
+ .details-body {
162
+ padding: 10px;
163
+ cursor: default;
164
+ }
165
+
166
+ .row {
167
+ display: flex;
168
+ justify-content: space-between;
169
+ align-items: center;
170
+ line-height: 1.4;
171
+ }
172
+
173
+ .main-row {
174
+ margin-bottom: 2px;
175
+ }
176
+
177
+ .left-group {
178
+ display: flex;
179
+ align-items: center;
180
+ gap: 6px;
181
+ flex-wrap: wrap;
182
+ }
183
+
184
+ .right-group {
185
+ text-align: right;
186
+ display: flex;
187
+ gap: 4px;
188
+ font-family: "B612", monospace;
189
+ font-size: 0.9em;
190
+ color: #333;
191
+ white-space: nowrap;
192
+ }
193
+
194
+ .ship-name {
195
+ font-size: 1.1em;
196
+ font-weight: bold;
197
+ }
198
+
199
+ .no-data {
200
+ color: #888;
201
+ font-style: italic;
202
+ font-size: 1em;
203
+ }
204
+
205
+ .destination {
206
+ font-size: 0.9em;
207
+ color: #666;
208
+ white-space: nowrap;
209
+ overflow: hidden;
210
+ text-overflow: ellipsis;
211
+ max-width: 200px;
212
+ }
213
+
214
+ .chip {
215
+ border-radius: 5px;
216
+ padding: 0px 5px;
217
+ font-family: "Inconsolata", monospace;
218
+ font-size: 1em;
219
+ white-space: nowrap;
220
+ }
221
+
222
+ .chip.blue {
223
+ background-color: #4c78a8;
224
+ color: white;
225
+ border: 1px solid #4c78a8;
226
+ }
227
+
228
+ .chip.yellow {
229
+ background-color: #f2cf5b;
230
+ color: black;
231
+ border: 1px solid #e0c050;
232
+ }
233
+
234
+ .sub-row .right-group {
235
+ color: #666;
236
+ }
237
+
238
+ .sep {
239
+ color: #aaa;
240
+ font-weight: normal;
241
+ }
242
+
243
+ /* details */
244
+ .details-header {
245
+ margin-bottom: 10px;
246
+ display: flex;
247
+ justify-content: space-between;
248
+ align-items: center;
249
+ }
250
+
251
+ .status-chip {
252
+ background-color: #eee;
253
+ padding: 2px 6px;
254
+ border-radius: 10px;
255
+ font-size: 0.9em;
256
+ color: #555;
257
+ }
258
+
259
+ .details-table {
260
+ border-collapse: collapse;
261
+ width: 100%;
262
+ }
263
+
264
+ .details-table td {
265
+ padding: 1px 0;
266
+ border: none;
267
+ }
268
+
269
+ .details-table .label {
270
+ text-align: right;
271
+ font-weight: bold;
272
+ padding-right: 10px;
273
+ white-space: nowrap;
274
+ width: 1%;
275
+ color: #555;
276
+ }
277
+ </style>
@@ -0,0 +1,458 @@
1
+ <template>
2
+ <div
3
+ v-if="tooltip.object"
4
+ class="deck-tooltip"
5
+ :style="{ left: `${tooltip.x}px`, top: `${tooltip.y}px` }"
6
+ >
7
+ <div class="tooltip-grid">
8
+ <div class="ship-name">
9
+ {{ tooltip.object.state.mmsi_info?.flag }}
10
+ {{ tooltip.object.state.ship_name || "N/A" }}
11
+ </div>
12
+ <div></div>
13
+
14
+ <div class="ship-type">{{ tooltip.object.state.ship_type }}</div>
15
+ <div class="mmsi">{{ tooltip.object.state.mmsi }}</div>
16
+ <div v-if="tooltip.object.state.speed" class="speed">
17
+ {{ tooltip.object.state.speed.toFixed(1) }} kts
18
+ </div>
19
+ <div v-if="tooltip.object.state.course" class="course">
20
+ {{ tooltip.object.state.course.toFixed(0) }}°
21
+ </div>
22
+ <div v-if="tooltip.object.state.destination" class="destination">
23
+ {{ tooltip.object.state.destination }}
24
+ </div>
25
+ </div>
26
+ </div>
27
+ </template>
28
+
29
+ <script setup lang="ts">
30
+ import { computed, inject, onUnmounted, ref, watch, reactive, type Ref } from "vue";
31
+ import { IconLayer, PolygonLayer } from "@deck.gl/layers";
32
+ import type { TangramApi, Entity, Disposable } from "@open-aviation/tangram-core/api";
33
+ import { oklchToDeckGLColor } from "@open-aviation/tangram-core/colour";
34
+ import type { Ship162Vessel } from ".";
35
+ import type { PickingInfo } from "@deck.gl/core";
36
+
37
+ // Type alias to match internal use
38
+ type ShipState = Ship162Vessel;
39
+
40
+ const tangramApi = inject<TangramApi>("tangramApi");
41
+ if (!tangramApi) {
42
+ throw new Error("assert: tangram api not provided");
43
+ }
44
+
45
+ const shipEntities = computed(
46
+ () => tangramApi.state.getEntitiesByType<ShipState>("ship162_ship").value
47
+ );
48
+ const activeEntities = computed(() => tangramApi.state.activeEntities.value);
49
+ const layerDisposable: Ref<Disposable | null> = ref(null);
50
+ const hullLayerDisposable: Ref<Disposable | null> = ref(null);
51
+ const zoom = computed(() => tangramApi.map.zoom.value);
52
+
53
+ const tooltip = reactive<{
54
+ x: number;
55
+ y: number;
56
+ object: Entity<ShipState> | null;
57
+ }>({ x: 0, y: 0, object: null });
58
+
59
+ const colors = {
60
+ passenger: oklchToDeckGLColor(0.65, 0.2, 260, 180), // blue
61
+ cargo: oklchToDeckGLColor(0.65, 0.15, 140, 180), // green
62
+ tanker: oklchToDeckGLColor(0.65, 0.2, 40, 180), // red-orange
63
+ high_speed: oklchToDeckGLColor(0.9, 0.25, 90, 180), // yellow
64
+ pleasure: oklchToDeckGLColor(0.65, 0.2, 330, 180), // magenta
65
+ fishing: oklchToDeckGLColor(0.7, 0.2, 200, 180), // cyan
66
+ special: oklchToDeckGLColor(0.75, 0.18, 70, 180), // orange
67
+ aton: oklchToDeckGLColor(0.9, 0.25, 90, 180), // yellow
68
+ sar: oklchToDeckGLColor(0.7, 0.25, 50, 180), // orange
69
+ default: oklchToDeckGLColor(0.6, 0, 0, 180), // grey
70
+ selected: oklchToDeckGLColor(0.7, 0.25, 20, 220) // bright red
71
+ };
72
+
73
+ const isStationary = (state: ShipState): boolean => {
74
+ if (state.speed !== undefined && state.speed < 1.0) return true;
75
+ if (
76
+ state.status === "At anchor" ||
77
+ state.status === "Moored" ||
78
+ state.status === "Aground"
79
+ )
80
+ return true;
81
+ return false;
82
+ };
83
+
84
+ const getIconColor = (
85
+ state: ShipState | undefined
86
+ ): [number, number, number, number] => {
87
+ if (!state) return colors.default;
88
+
89
+ switch (state.ship_type) {
90
+ case "Passenger":
91
+ return colors.passenger;
92
+ case "Cargo":
93
+ return colors.cargo;
94
+ case "Tanker":
95
+ return colors.tanker;
96
+ case "High Speed Craft":
97
+ return colors.high_speed;
98
+ case "Pleasure craft":
99
+ case "Sailing":
100
+ return colors.pleasure;
101
+ case "Fishing":
102
+ return colors.fishing;
103
+ case "Tug":
104
+ case "Towing":
105
+ case "Towing, large":
106
+ case "Pilot Vessel":
107
+ case "Search and Rescue":
108
+ case "Port Tender":
109
+ case "Dredging or underwater operations":
110
+ case "Diving operations":
111
+ case "Military operations":
112
+ case "Law Enforcement":
113
+ case "Anti-Pollution Equipment":
114
+ return colors.special;
115
+ }
116
+
117
+ if (state.mmsi_info) {
118
+ switch (state.ship_type) {
119
+ case "SAR Aircraft":
120
+ return colors.sar;
121
+ case "AIS AtoN":
122
+ return colors.aton;
123
+ }
124
+ }
125
+
126
+ return colors.default;
127
+ };
128
+
129
+ const pointySvg = (color: string) =>
130
+ `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="-12 -12 24 24" fill="${color}" stroke="black" stroke-width="0.5"><path d="M 0 -10 L 4 8 L 0 5 L -4 8 Z"/></svg>`;
131
+ const circleSvg = (color: string) =>
132
+ `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="-12 -12 24 24" fill="${color}" stroke="black" stroke-width="0.5"><circle cx="0" cy="0" r="4"/></svg>`;
133
+ const diamondSvg = (color: string) =>
134
+ `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="-12 -12 24 24" fill="${color}" stroke="black" stroke-width="0.5"><path d="M 0 -8 L 8 0 L 0 8 L -8 0 Z"/></svg>`;
135
+
136
+ const iconCache = new Map<string, string>();
137
+
138
+ const rgbToHex = (r: number, g: number, b: number) =>
139
+ "#" +
140
+ [r, g, b]
141
+ .map(x => {
142
+ const hex = x.toString(16);
143
+ return hex.length === 1 ? "0" + hex : hex;
144
+ })
145
+ .join("");
146
+
147
+ const getShipIcon = (
148
+ state: ShipState,
149
+ isSelected: boolean
150
+ ): { url: string; id: string } => {
151
+ const stationary = isStationary(state);
152
+ const isAton = state.ship_type === "AIS AtoN";
153
+ const colorArray = isSelected ? colors.selected : getIconColor(state);
154
+ const color = rgbToHex(colorArray[0], colorArray[1], colorArray[2]);
155
+ const cacheKey = `${color}-${stationary}-${isAton}`;
156
+
157
+ if (iconCache.has(cacheKey)) {
158
+ return { url: iconCache.get(cacheKey)!, id: cacheKey };
159
+ }
160
+
161
+ let svg;
162
+ if (isAton) {
163
+ svg = diamondSvg(color);
164
+ } else if (stationary) {
165
+ svg = circleSvg(color);
166
+ } else {
167
+ svg = pointySvg(color);
168
+ }
169
+
170
+ const dataUrl = `data:image/svg+xml;base64,${btoa(svg)}`;
171
+ iconCache.set(cacheKey, dataUrl);
172
+ return { url: dataUrl, id: cacheKey };
173
+ };
174
+
175
+ /**
176
+ * Calculate ship hull polygon coordinates based on AIS dimensions.
177
+ * Returns a polygon in [lon, lat] format relative to ship's position.
178
+ * This implementation matches the algorithm in AISCatcher's script.js.
179
+ */
180
+ const getShipHullPolygon = (state: ShipState): number[][] | null => {
181
+ const {
182
+ to_bow,
183
+ to_stern,
184
+ to_port,
185
+ to_starboard,
186
+ latitude,
187
+ longitude,
188
+ course,
189
+ heading
190
+ } = state;
191
+
192
+ // Check if we have all required dimension data
193
+ if (!to_bow || !to_stern || !to_port || !to_starboard) {
194
+ return null;
195
+ }
196
+
197
+ // Use heading if available, otherwise course
198
+ let finalHeading = heading;
199
+
200
+ if (finalHeading === null || finalHeading === undefined) {
201
+ if (course != null && state.speed && state.speed > 1) {
202
+ finalHeading = course;
203
+ } else {
204
+ // No valid heading - can't draw oriented shape
205
+ return null;
206
+ }
207
+ }
208
+
209
+ const coordinate = [longitude, latitude];
210
+
211
+ // Calculate offset for 1 meter in lat/lon at this position
212
+ // This matches aiscatcher.js's calcOffset1M
213
+ // const R = 6378137; // Earth radius in meters (WGS84)
214
+ const cos100R = 0.9999999998770914; // cos(100m / R)
215
+ const sin100R = 1.567855942823164e-5; // sin(100m / R)
216
+ const rad = Math.PI / 180;
217
+ const radInv = 180 / Math.PI;
218
+
219
+ const lat = coordinate[1] * rad;
220
+ const rheading = ((finalHeading + 360) % 360) * rad;
221
+ const sinLat = Math.sin(lat);
222
+ const cosLat = Math.cos(lat);
223
+
224
+ const sinLat2 = sinLat * cos100R + cosLat * sin100R * Math.cos(rheading);
225
+ const lat2 = Math.asin(sinLat2);
226
+ const deltaLon = Math.atan2(
227
+ Math.sin(rheading) * sin100R * cosLat,
228
+ cos100R - sinLat * sinLat2
229
+ );
230
+
231
+ // deltaNorth: [deltaLat, deltaLon] for 1m north
232
+ // deltaEast: [deltaLat, deltaLon] for 1m east (perpendicular to north)
233
+ const deltaNorth = [(lat2 * radInv - coordinate[1]) / 100, (deltaLon * radInv) / 100];
234
+ // Perpendicular vector for east (rotate by +90deg)
235
+ const rheadingEast = ((finalHeading + 90 + 360) % 360) * rad;
236
+ const sinLat2East = sinLat * cos100R + cosLat * sin100R * Math.cos(rheadingEast);
237
+ const lat2East = Math.asin(sinLat2East);
238
+ const deltaLonEast = Math.atan2(
239
+ Math.sin(rheadingEast) * sin100R * cosLat,
240
+ cos100R - sinLat * sinLat2East
241
+ );
242
+ const deltaEast = [
243
+ (lat2East * radInv - coordinate[1]) / 100,
244
+ (deltaLonEast * radInv) / 100
245
+ ];
246
+
247
+ // Move function: [lon + delta[1] * distance, lat + delta[0] * distance]
248
+ const calcMove = (coord: number[], delta: number[], distance: number): number[] => {
249
+ return [coord[0] + delta[1] * distance, coord[1] + delta[0] * distance];
250
+ };
251
+
252
+ // Ship outline points (AISCatcher logic)
253
+ // const bow = calcMove(coordinate, deltaNorth, to_bow);
254
+ const stern = calcMove(coordinate, deltaNorth, -to_stern);
255
+
256
+ const A = calcMove(stern, deltaEast, to_starboard);
257
+ const B = calcMove(stern, deltaEast, -to_port);
258
+ const C = calcMove(B, deltaNorth, 0.8 * (to_bow + to_stern));
259
+ const Dmid = calcMove(C, deltaEast, 0.5 * (to_starboard + to_port));
260
+ const D = calcMove(Dmid, deltaNorth, 0.2 * (to_bow + to_stern));
261
+ const E = calcMove(C, deltaEast, to_starboard + to_port);
262
+
263
+ // Return ship outline as closed polygon
264
+ return [A, B, C, D, E, A];
265
+ };
266
+
267
+ const onClick = (
268
+ info: PickingInfo<Entity<ShipState>>,
269
+ event: { srcEvent: { originalEvent: MouseEvent } }
270
+ ) => {
271
+ if (!info.object) return;
272
+ const srcEvent = event.srcEvent.originalEvent;
273
+ const exclusive = !srcEvent.ctrlKey && !srcEvent.altKey && !srcEvent.metaKey;
274
+
275
+ if (exclusive) {
276
+ tangramApi.state.selectEntity(info.object, true);
277
+ } else {
278
+ if (tangramApi.state.activeEntities.value.has(info.object.id)) {
279
+ tangramApi.state.deselectEntity(info.object.id);
280
+ } else {
281
+ tangramApi.state.selectEntity(info.object, false);
282
+ }
283
+ }
284
+ };
285
+
286
+ watch(
287
+ [shipEntities, activeEntities, () => tangramApi.map.isReady.value, zoom],
288
+ ([entities, currentActiveEntities, isMapReady, currentZoom]) => {
289
+ if (!entities || !isMapReady) return;
290
+
291
+ if (layerDisposable.value) layerDisposable.value.dispose();
292
+ if (hullLayerDisposable.value) {
293
+ hullLayerDisposable.value.dispose();
294
+ hullLayerDisposable.value = null;
295
+ }
296
+
297
+ // added a 0.9 factor because it looked slightly too large
298
+ const sizeScale = 0.9 * Math.min(Math.max(0.5, Math.pow(2, currentZoom - 10)), 2);
299
+ const showHulls = currentZoom >= 12;
300
+ const selectedIds = new Set(currentActiveEntities.keys());
301
+
302
+ // Ship hull layer (only visible at high zoom)
303
+ const baseData: Entity<ShipState>[] = [];
304
+ const selectedData: Entity<ShipState>[] = [];
305
+ for (const d of entities.values()) {
306
+ if (selectedIds.has(d.id)) {
307
+ selectedData.push(d);
308
+ } else {
309
+ baseData.push(d);
310
+ }
311
+ }
312
+ const allData = baseData.concat(selectedData);
313
+
314
+ if (showHulls) {
315
+ const hullData = allData
316
+ .map(entity => ({
317
+ entity,
318
+ polygon: getShipHullPolygon(entity.state)
319
+ }))
320
+ .filter(({ polygon }) => polygon !== null);
321
+
322
+ const hullLayer = new PolygonLayer<{
323
+ entity: Entity<ShipState>;
324
+ polygon: number[][];
325
+ }>({
326
+ id: "ship-hull-layer",
327
+ data: hullData,
328
+ pickable: true,
329
+ stroked: true,
330
+ filled: true,
331
+ wireframe: false,
332
+ lineWidthMinPixels: 1,
333
+ getPolygon: d => d.polygon,
334
+ getFillColor: d => {
335
+ return selectedIds.has(d.entity.id)
336
+ ? colors.selected
337
+ : getIconColor(d.entity.state);
338
+ },
339
+ getLineColor: d => {
340
+ return selectedIds.has(d.entity.id)
341
+ ? colors.selected
342
+ : getIconColor(d.entity.state);
343
+ },
344
+ getLineWidth: 0.5,
345
+ onClick: (info: PickingInfo, event: MjolnirEvent) => {
346
+ if (!info.object) return;
347
+ const entity = (info.object as any).entity;
348
+ const mockInfo = { ...info, object: entity } as PickingInfo<
349
+ Entity<ShipState>
350
+ >;
351
+ onClick(mockInfo, event);
352
+ },
353
+ updateTriggers: {
354
+ getFillColor: [currentActiveEntities]
355
+ }
356
+ });
357
+
358
+ const hullDisposable = tangramApi.map.setLayer(hullLayer);
359
+ if (!hullLayerDisposable.value) {
360
+ hullLayerDisposable.value = hullDisposable;
361
+ }
362
+ }
363
+
364
+ // Icon layer (always visible, but smaller when hulls are shown)
365
+ const shipLayer = new IconLayer<Entity<ShipState>>({
366
+ id: "ship-layer",
367
+ data: allData,
368
+ pickable: true,
369
+ billboard: false,
370
+ getIcon: d => {
371
+ const { url, id } = getShipIcon(d.state, selectedIds.has(d.id));
372
+ return {
373
+ url,
374
+ id,
375
+ width: 24,
376
+ height: 24,
377
+ anchorY: 12,
378
+ mask: false
379
+ };
380
+ },
381
+ sizeScale: showHulls ? sizeScale * 0.5 : sizeScale,
382
+ getPosition: d => [d.state.longitude!, d.state.latitude!],
383
+ getSize: 24,
384
+ getAngle: d => {
385
+ if (isStationary(d.state)) return 0;
386
+ return -(d.state.course || d.state.heading || 0);
387
+ },
388
+ onClick: onClick,
389
+ onHover: info => {
390
+ if (info.object) {
391
+ tooltip.object = info.object;
392
+ tooltip.x = info.x;
393
+ tooltip.y = info.y;
394
+ } else {
395
+ tooltip.object = null;
396
+ }
397
+ },
398
+ updateTriggers: {
399
+ getIcon: Array.from(currentActiveEntities.keys()).sort().join(","),
400
+ sizeScale: [showHulls]
401
+ },
402
+ // required for globe: https://github.com/visgl/deck.gl/issues/9777#issuecomment-3628393899
403
+ parameters: {
404
+ cullMode: "none"
405
+ }
406
+ });
407
+
408
+ layerDisposable.value = tangramApi.map.setLayer(shipLayer);
409
+ },
410
+ { immediate: true }
411
+ );
412
+
413
+ onUnmounted(() => {
414
+ layerDisposable.value?.dispose();
415
+ hullLayerDisposable.value?.dispose();
416
+ });
417
+ </script>
418
+
419
+ <style>
420
+ .deck-tooltip {
421
+ position: absolute;
422
+ background: white;
423
+ color: black;
424
+ padding: 4px 8px;
425
+ border-radius: 4px;
426
+ font-size: 11px;
427
+ font-family: "B612", sans-serif;
428
+ pointer-events: none;
429
+ transform: translate(10px, -20px);
430
+ z-index: 10;
431
+ min-width: 120px;
432
+ }
433
+ .tooltip-grid {
434
+ display: grid;
435
+ grid-template-columns: auto auto;
436
+ align-items: baseline;
437
+ column-gap: 0.5rem;
438
+ }
439
+ .ship-name {
440
+ font-weight: bold;
441
+ grid-column: 1 / 2;
442
+ }
443
+ .mmsi {
444
+ text-align: right;
445
+ grid-column: 2 / 3;
446
+ }
447
+ .speed {
448
+ grid-column: 1 / 2;
449
+ }
450
+ .course {
451
+ text-align: right;
452
+ grid-column: 2 / 3;
453
+ }
454
+ .destination {
455
+ grid-column: 1 / -1;
456
+ margin-top: 2px;
457
+ }
458
+ </style>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import { inject, onUnmounted, ref, watch, type Ref } from "vue";
3
+ import { PathLayer } from "@deck.gl/layers";
4
+ import type { TangramApi, Disposable } from "@open-aviation/tangram-core/api";
5
+ import { shipStore } from "./store";
6
+ import type { Layer } from "@deck.gl/core";
7
+
8
+ const tangramApi = inject<TangramApi>("tangramApi");
9
+ if (!tangramApi) throw new Error("assert: tangram api not provided");
10
+
11
+ const layerDisposable: Ref<Disposable | null> = ref(null);
12
+
13
+ const updateLayer = () => {
14
+ if (layerDisposable.value) {
15
+ layerDisposable.value.dispose();
16
+ layerDisposable.value = null;
17
+ }
18
+
19
+ const allPaths = Array.from(shipStore.selected.entries())
20
+ .filter(([, data]) => data.trajectory.length > 1)
21
+ .map(([id, data]) => ({
22
+ id,
23
+ path: data.trajectory
24
+ .filter(p => p.latitude != null && p.longitude != null)
25
+ .map(p => [p.longitude, p.latitude])
26
+ }));
27
+
28
+ if (allPaths.length > 0) {
29
+ const trailLayer = new PathLayer({
30
+ id: `ship-trails`,
31
+ data: allPaths,
32
+ pickable: false,
33
+ widthScale: 1,
34
+ widthMinPixels: 2,
35
+ getPath: d => d.path,
36
+ getColor: [128, 0, 128, 255],
37
+ getWidth: 2
38
+ }) as Layer;
39
+ layerDisposable.value = tangramApi.map.addLayer(trailLayer);
40
+ }
41
+ };
42
+
43
+ watch(() => shipStore.version, updateLayer);
44
+
45
+ onUnmounted(() => {
46
+ layerDisposable.value?.dispose();
47
+ });
48
+ </script>
@@ -0,0 +1,133 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ import tangram_core
5
+ from fastapi import APIRouter, HTTPException
6
+ from fastapi.responses import Response
7
+ from pydantic import TypeAdapter
8
+
9
+ try:
10
+ import polars as pl
11
+
12
+ _HISTORY_AVAILABLE = True
13
+ except ImportError:
14
+ _HISTORY_AVAILABLE = False
15
+
16
+
17
+ router = APIRouter(
18
+ prefix="/ship162",
19
+ tags=["ship162"],
20
+ responses={404: {"description": "Not found"}},
21
+ )
22
+
23
+
24
+ @router.get("/data/{mmsi}")
25
+ async def get_trajectory_data(
26
+ mmsi: int, backend_state: tangram_core.InjectBackendState
27
+ ) -> list[dict[str, Any]]:
28
+ """Get the full trajectory for a given ship MMSI."""
29
+ if not _HISTORY_AVAILABLE:
30
+ raise HTTPException(
31
+ status_code=501,
32
+ detail="History feature is not installed. "
33
+ "Install with `pip install 'tangram_ship162[history]'`",
34
+ )
35
+
36
+ redis_key = "tangram:history:table_uri:ship162"
37
+ table_uri_bytes = await backend_state.redis_client.get(redis_key)
38
+
39
+ if not table_uri_bytes:
40
+ raise HTTPException(
41
+ status_code=404,
42
+ detail=(
43
+ "Table 'ship162' not found.\nhelp: is the history service running?"
44
+ ),
45
+ )
46
+ table_uri = table_uri_bytes.decode("utf-8")
47
+
48
+ try:
49
+ df = (
50
+ pl.scan_delta(table_uri)
51
+ .filter(pl.col("mmsi") == mmsi)
52
+ .with_columns(pl.col("timestamp").dt.epoch(time_unit="s"))
53
+ .sort("timestamp")
54
+ .collect()
55
+ )
56
+ return Response(df.write_json(), media_type="application/json")
57
+ except Exception as e:
58
+ raise HTTPException(
59
+ status_code=500, detail=f"Failed to query trajectory data: {e}"
60
+ )
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ShipsConfig(
65
+ tangram_core.config.HasTopbarUiConfig, tangram_core.config.HasSidebarUiConfig
66
+ ):
67
+ ship162_channel: str = "ship162"
68
+ history_table_name: str = "ship162"
69
+ history_control_channel: str = "history:control"
70
+ state_vector_expire: int = 600 # 10 minutes
71
+ stream_interval_secs: float = 1.0
72
+ log_level: str = "INFO"
73
+ history_buffer_size: int = 100_000
74
+ history_flush_interval_secs: int = 5
75
+ history_optimize_interval_secs: int = 120
76
+ history_optimize_target_file_size: int = 134217728
77
+ history_vacuum_interval_secs: int = 120
78
+ history_vacuum_retention_period_secs: int | None = 120
79
+ topbar_order: int = 100
80
+ sidebar_order: int = 100
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class FrontendShipsConfig(
85
+ tangram_core.config.HasTopbarUiConfig, tangram_core.config.HasSidebarUiConfig
86
+ ):
87
+ topbar_order: int
88
+ sidebar_order: int
89
+
90
+
91
+ def transform_config(config_dict: dict[str, Any]) -> FrontendShipsConfig:
92
+ config = TypeAdapter(ShipsConfig).validate_python(config_dict)
93
+ return FrontendShipsConfig(
94
+ topbar_order=config.topbar_order,
95
+ sidebar_order=config.sidebar_order,
96
+ )
97
+
98
+
99
+ plugin = tangram_core.Plugin(
100
+ frontend_path="dist-frontend",
101
+ routers=[router],
102
+ into_frontend_config_function=transform_config,
103
+ )
104
+
105
+
106
+ @plugin.register_service()
107
+ async def run_ships(backend_state: tangram_core.BackendState) -> None:
108
+ from . import _ships
109
+
110
+ plugin_config = backend_state.config.plugins.get("tangram_ship162", {})
111
+ config_ships = TypeAdapter(ShipsConfig).validate_python(plugin_config)
112
+
113
+ default_log_level = plugin_config.get(
114
+ "log_level", backend_state.config.core.log_level
115
+ )
116
+
117
+ _ships.init_tracing_stderr(default_log_level)
118
+
119
+ rust_config = _ships.ShipsConfig(
120
+ redis_url=backend_state.config.core.redis_url,
121
+ ship162_channel=config_ships.ship162_channel,
122
+ history_control_channel=config_ships.history_control_channel,
123
+ state_vector_expire=config_ships.state_vector_expire,
124
+ stream_interval_secs=config_ships.stream_interval_secs,
125
+ history_table_name=config_ships.history_table_name,
126
+ history_buffer_size=config_ships.history_buffer_size,
127
+ history_flush_interval_secs=config_ships.history_flush_interval_secs,
128
+ history_optimize_interval_secs=config_ships.history_optimize_interval_secs,
129
+ history_optimize_target_file_size=config_ships.history_optimize_target_file_size,
130
+ history_vacuum_interval_secs=config_ships.history_vacuum_interval_secs,
131
+ history_vacuum_retention_period_secs=config_ships.history_vacuum_retention_period_secs,
132
+ )
133
+ await _ships.run_ships(rust_config)
@@ -0,0 +1,62 @@
1
+ # This file is automatically generated by pyo3_stub_gen
2
+ # ruff: noqa: E501, F401
3
+
4
+ import builtins
5
+ import typing
6
+
7
+ @typing.final
8
+ class ShipsConfig:
9
+ @property
10
+ def redis_url(self) -> builtins.str: ...
11
+ @redis_url.setter
12
+ def redis_url(self, value: builtins.str) -> None: ...
13
+ @property
14
+ def ship162_channel(self) -> builtins.str: ...
15
+ @ship162_channel.setter
16
+ def ship162_channel(self, value: builtins.str) -> None: ...
17
+ @property
18
+ def history_control_channel(self) -> builtins.str: ...
19
+ @history_control_channel.setter
20
+ def history_control_channel(self, value: builtins.str) -> None: ...
21
+ @property
22
+ def state_vector_expire(self) -> builtins.int: ...
23
+ @state_vector_expire.setter
24
+ def state_vector_expire(self, value: builtins.int) -> None: ...
25
+ @property
26
+ def stream_interval_secs(self) -> builtins.float: ...
27
+ @stream_interval_secs.setter
28
+ def stream_interval_secs(self, value: builtins.float) -> None: ...
29
+ @property
30
+ def history_table_name(self) -> builtins.str: ...
31
+ @history_table_name.setter
32
+ def history_table_name(self, value: builtins.str) -> None: ...
33
+ @property
34
+ def history_buffer_size(self) -> builtins.int: ...
35
+ @history_buffer_size.setter
36
+ def history_buffer_size(self, value: builtins.int) -> None: ...
37
+ @property
38
+ def history_flush_interval_secs(self) -> builtins.int: ...
39
+ @history_flush_interval_secs.setter
40
+ def history_flush_interval_secs(self, value: builtins.int) -> None: ...
41
+ @property
42
+ def history_optimize_interval_secs(self) -> builtins.int: ...
43
+ @history_optimize_interval_secs.setter
44
+ def history_optimize_interval_secs(self, value: builtins.int) -> None: ...
45
+ @property
46
+ def history_optimize_target_file_size(self) -> builtins.int: ...
47
+ @history_optimize_target_file_size.setter
48
+ def history_optimize_target_file_size(self, value: builtins.int) -> None: ...
49
+ @property
50
+ def history_vacuum_interval_secs(self) -> builtins.int: ...
51
+ @history_vacuum_interval_secs.setter
52
+ def history_vacuum_interval_secs(self, value: builtins.int) -> None: ...
53
+ @property
54
+ def history_vacuum_retention_period_secs(self) -> typing.Optional[builtins.int]: ...
55
+ @history_vacuum_retention_period_secs.setter
56
+ def history_vacuum_retention_period_secs(self, value: typing.Optional[builtins.int]) -> None: ...
57
+ def __new__(cls, redis_url: builtins.str, ship162_channel: builtins.str, history_control_channel: builtins.str, state_vector_expire: builtins.int, stream_interval_secs: builtins.float, history_table_name: builtins.str, history_buffer_size: builtins.int, history_flush_interval_secs: builtins.int, history_optimize_interval_secs: builtins.int, history_optimize_target_file_size: builtins.int, history_vacuum_interval_secs: builtins.int, history_vacuum_retention_period_secs: typing.Optional[builtins.int]) -> ShipsConfig: ...
58
+
59
+ def init_tracing_stderr(filter_str: builtins.str) -> None: ...
60
+
61
+ def run_ships(config: ShipsConfig) -> typing.Any: ...
62
+
@@ -0,0 +1,172 @@
1
+ import { watch } from "vue";
2
+ import type { TangramApi, Entity } from "@open-aviation/tangram-core/api";
3
+ import ShipLayer from "./ShipLayer.vue";
4
+ import ShipCountWidget from "./ShipCountWidget.vue";
5
+ import ShipInfoWidget from "./ShipInfoWidget.vue";
6
+ import ShipTrailLayer from "./ShipTrailLayer.vue";
7
+ import { shipStore, type ShipSelectionData } from "./store";
8
+
9
+ const ENTITY_TYPE = "ship162_ship";
10
+
11
+ interface Ship162FrontendConfig {
12
+ topbar_order: number;
13
+ sidebar_order: number;
14
+ }
15
+
16
+ export interface MmsiInfo {
17
+ country: string;
18
+ flag: string;
19
+ "iso-3166-1": string;
20
+ }
21
+
22
+ export interface Ship162Vessel {
23
+ mmsi: string;
24
+ timestamp: number;
25
+ latitude?: number;
26
+ longitude?: number;
27
+ ship_name?: string;
28
+ course?: number;
29
+ speed?: number;
30
+ destination?: string;
31
+ mmsi_info?: MmsiInfo;
32
+ ship_type?: string;
33
+ status?: string;
34
+ callsign?: string;
35
+ heading?: number;
36
+ imo?: number;
37
+ draught?: number;
38
+ to_bow?: number;
39
+ to_stern?: number;
40
+ to_port?: number;
41
+ to_starboard?: number;
42
+ turn?: number;
43
+ }
44
+
45
+ export function install(api: TangramApi, config?: Ship162FrontendConfig) {
46
+ api.ui.registerWidget("ship162-count-widget", "TopBar", ShipCountWidget, {
47
+ priority: config?.topbar_order
48
+ });
49
+ api.ui.registerWidget("ship162-info-widget", "SideBar", ShipInfoWidget, {
50
+ priority: config?.sidebar_order,
51
+ title: "Ship Details",
52
+ relevantFor: ENTITY_TYPE
53
+ });
54
+ api.ui.registerWidget("ship162-ship-layer", "MapOverlay", ShipLayer);
55
+ api.ui.registerWidget("ship162-trail-layer", "MapOverlay", ShipTrailLayer);
56
+ api.state.registerEntityType(ENTITY_TYPE);
57
+
58
+ (async () => {
59
+ try {
60
+ const connectionId = await api.realtime.ensureConnected();
61
+ await subscribeToShipData(api, connectionId);
62
+ } catch (e) {
63
+ console.error("failed initializing ship162 realtime subscription", e);
64
+ }
65
+ })();
66
+
67
+ watch(
68
+ () => api.state.activeEntities.value,
69
+ async newEntities => {
70
+ const currentIds = new Set<string>();
71
+ for (const [id, entity] of newEntities) {
72
+ if (entity.type === ENTITY_TYPE) {
73
+ currentIds.add(id);
74
+ }
75
+ }
76
+
77
+ for (const id of shipStore.selected.keys()) {
78
+ if (!currentIds.has(id)) {
79
+ shipStore.selected.delete(id);
80
+ }
81
+ }
82
+
83
+ for (const id of currentIds) {
84
+ if (!shipStore.selected.has(id)) {
85
+ const selectionData: ShipSelectionData = {
86
+ trajectory: [],
87
+ loading: true,
88
+ error: null
89
+ };
90
+ shipStore.selected.set(id, selectionData);
91
+ fetchTrajectory(id);
92
+ }
93
+ }
94
+ shipStore.version++;
95
+ }
96
+ );
97
+ }
98
+
99
+ async function fetchTrajectory(mmsi: string) {
100
+ const data = shipStore.selected.get(mmsi);
101
+ if (!data) return;
102
+
103
+ try {
104
+ const response = await fetch(`/ship162/data/${mmsi}`);
105
+ if (!response.ok) throw new Error("Failed to fetch trajectory");
106
+ const trajData = await response.json();
107
+
108
+ if (shipStore.selected.has(mmsi)) {
109
+ const currentData = shipStore.selected.get(mmsi)!;
110
+ currentData.trajectory = [...trajData, ...currentData.trajectory];
111
+ shipStore.version++;
112
+ }
113
+ } catch (err: unknown) {
114
+ if (shipStore.selected.has(mmsi)) {
115
+ shipStore.selected.get(mmsi)!.error = (err as Error).message;
116
+ }
117
+ } finally {
118
+ if (shipStore.selected.has(mmsi)) {
119
+ shipStore.selected.get(mmsi)!.loading = false;
120
+ }
121
+ }
122
+ }
123
+
124
+ async function subscribeToShipData(api: TangramApi, connectionId: string) {
125
+ const topic = `streaming-${connectionId}:new-ship162-data`;
126
+ try {
127
+ await api.realtime.subscribe<{ ship: Ship162Vessel[]; count: number }>(
128
+ topic,
129
+ payload => {
130
+ const entities: Entity[] = payload.ship.map(ship => ({
131
+ id: ship.mmsi.toString(),
132
+ type: ENTITY_TYPE,
133
+ state: ship
134
+ }));
135
+ api.state.replaceAllEntitiesByType(ENTITY_TYPE, entities);
136
+ api.state.setTotalCount(ENTITY_TYPE, payload.count);
137
+
138
+ let hasUpdates = false;
139
+ for (const [id, data] of shipStore.selected) {
140
+ const entityMap =
141
+ api.state.getEntitiesByType<Ship162Vessel>(ENTITY_TYPE).value;
142
+ const entity = entityMap.get(id);
143
+
144
+ if (
145
+ entity &&
146
+ entity.state &&
147
+ entity.state.latitude &&
148
+ entity.state.longitude
149
+ ) {
150
+ const updated = entity.state;
151
+ const last = data.trajectory[data.trajectory.length - 1];
152
+ const timestamp = updated.timestamp;
153
+
154
+ if (!last || Math.abs(last.timestamp - timestamp) > 0.5) {
155
+ data.trajectory.push({
156
+ ...updated,
157
+ timestamp: timestamp
158
+ });
159
+ hasUpdates = true;
160
+ }
161
+ }
162
+ }
163
+ if (hasUpdates) {
164
+ shipStore.version++;
165
+ }
166
+ }
167
+ );
168
+ await api.realtime.publish("system:join-streaming", { connectionId });
169
+ } catch (e) {
170
+ console.error(`failed to subscribe to ${topic}`, e);
171
+ }
172
+ }
@@ -0,0 +1,13 @@
1
+ import { reactive } from "vue";
2
+ import type { Ship162Vessel } from ".";
3
+
4
+ export interface ShipSelectionData {
5
+ trajectory: Ship162Vessel[];
6
+ loading: boolean;
7
+ error: string | null;
8
+ }
9
+
10
+ export const shipStore = reactive({
11
+ selected: new Map<string, ShipSelectionData>(),
12
+ version: 0
13
+ });
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: tangram_ship162
3
+ Version: 0.2.1
4
+ Classifier: Development Status :: 4 - Beta
5
+ Classifier: Intended Audience :: Science/Research
6
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Programming Language :: Rust
14
+ Classifier: Topic :: Scientific/Engineering :: Visualization
15
+ Requires-Dist: tangram-core>=0.2.0
16
+ Requires-Dist: polars[deltalake] ; extra == 'history'
17
+ Provides-Extra: history
18
+ Summary: AIS maritime data integration plugin for tangram
19
+ Home-Page: https://mode-s.org/tangram/
20
+ Author-email: Abraham Cheung <abraham@ylcheung.com>
21
+ License-Expression: AGPL-3.0
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
24
+
25
+ # tangram_ship162
26
+
27
+ The `tangram_ship162` plugin integrates AIS data from a `ship162` instance, enabling real-time visualization and historical analysis of maritime traffic.
28
+
29
+ It provides:
30
+
31
+ - A background service to ingest AIS messages.
32
+ - A REST API endpoint to fetch ship trajectories.
33
+ - Frontend widgets for visualizing ships on the map.
34
+
35
+ ## About Tangram
36
+
37
+ `tangram_ship162` is a plugin for `tangram`, an open framework for modular, real-time air traffic management research.
38
+
39
+ - Documentation: <https://mode-s.org/tangram/>
40
+ - Repository: <https://github.com/open-aviation/tangram>
41
+
42
+ Installation:
43
+
44
+ ```sh
45
+ # cli via uv
46
+ uv tool install --with tangram-ship162 tangram-core
47
+ # with pip
48
+ pip install tangram-core tangram-ship162
49
+ ```
50
+
@@ -0,0 +1,14 @@
1
+ tangram_ship162-0.2.1.dist-info/METADATA,sha256=9A0fh7-jA4fSSUffD4Wj1155VyhcqVj3nuGnnFZcoSE,1742
2
+ tangram_ship162-0.2.1.dist-info/WHEEL,sha256=vUkIH7fhISEPmN8u5QEHgBl-TWt1VoFccM0v25sskao,109
3
+ tangram_ship162-0.2.1.dist-info/entry_points.txt,sha256=WyeOkUVEEot9oDqXQUIDv2f0I_XlMXxHApHcqzwFcxc,62
4
+ tangram_ship162.libs/libgcc_s-39080030.so.1,sha256=fIO6GHOh8Ft9CR0Geu7wSUb9Xnl122iTtrxQQ9TAkTQ,789673
5
+ tangram_ship162/ShipCountWidget.vue,sha256=ycGhbPoR445W4kQUh93i-JDh_zdfHaIwDw2BYEAzEIU,1103
6
+ tangram_ship162/ShipInfoWidget.vue,sha256=sdgr6GTXMnBI3S1dZccs2-EZW57tIhZNWyI9ArQ8xiY,6063
7
+ tangram_ship162/ShipLayer.vue,sha256=LUGmXuECON37bojVQebVE__mWh51Svd88WYdqHXVeBU,14085
8
+ tangram_ship162/ShipTrailLayer.vue,sha256=xiw9rbgflyV5-UHqTcaDripCg8en3jOF_9UFlDAUs-c,1386
9
+ tangram_ship162/__init__.py,sha256=aRMjqrGRvzby7g67q2oTnnkiSuJQPkEKznGZ64g4mik,4385
10
+ tangram_ship162/_ships.cpython-314-aarch64-linux-musl.so,sha256=RyKhPHzcLBbxEunPagPUc3AD_dFeR04PeMrBuWyt0U0,7212481
11
+ tangram_ship162/_ships.pyi,sha256=LZ3YDHMJpx_i7XOkW0yp_-C2za39B8WgDgG7gHOKobU,3008
12
+ tangram_ship162/index.ts,sha256=IZF1aq4lhmCyP4ZpbJ5CA4ZXTirr2ye__b6jtLbJc1c,4962
13
+ tangram_ship162/store.ts,sha256=iFFqGo03xa--uUPgZnWSMsfCxWtoT8xTHGmvo8vwV6Q,291
14
+ tangram_ship162-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: maturin (1.10.2)
3
+ Root-Is-Purelib: false
4
+ Tag: cp314-cp314-musllinux_1_1_aarch64
@@ -0,0 +1,2 @@
1
+ [tangram_core.plugins]
2
+ tangram_ship162=tangram_ship162:plugin