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.
- tangram_ship162/ShipCountWidget.vue +50 -0
- tangram_ship162/ShipInfoWidget.vue +277 -0
- tangram_ship162/ShipLayer.vue +458 -0
- tangram_ship162/ShipTrailLayer.vue +48 -0
- tangram_ship162/__init__.py +133 -0
- tangram_ship162/_ships.cpython-314-aarch64-linux-musl.so +0 -0
- tangram_ship162/_ships.pyi +62 -0
- tangram_ship162/index.ts +172 -0
- tangram_ship162/store.ts +13 -0
- tangram_ship162-0.2.1.dist-info/METADATA +50 -0
- tangram_ship162-0.2.1.dist-info/RECORD +14 -0
- tangram_ship162-0.2.1.dist-info/WHEEL +4 -0
- tangram_ship162-0.2.1.dist-info/entry_points.txt +2 -0
- tangram_ship162.libs/libgcc_s-39080030.so.1 +0 -0
|
@@ -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)
|
|
Binary file
|
|
@@ -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
|
+
|
tangram_ship162/index.ts
ADDED
|
@@ -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
|
+
}
|
tangram_ship162/store.ts
ADDED
|
@@ -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,,
|
|
Binary file
|