Skip to content

Commit 39db1d8

Browse files
feat(mapcn-vue): add 3 defense & C4ISR demo examples with terra-draw geofence
- Multi-Drone C2 Dashboard: 4 UAVs + 2 UGVs with animated patrol trails, unit selection, telemetry readout, and terra-draw polygon geofence with breach detection - 3D Battlefield Terrain: animated troop movement replay over Ladakh with mission phase timeline, unit type filtering, and playback controls - Sensor Network & EW Coverage: 16 distributed sensors (acoustic, radar, LoRa, jammer) with pulsating detection radii, coverage zones, simulated threat events, and scrolling marquee alert ticker - New 'Defense & C4ISR' category in examples index - Add terra-draw and terra-draw-maplibre-gl-adapter dependencies Signed-off-by: Vinayak Kulkarni <19776877+vinayakkulkarni@users.noreply.github.com>
1 parent 4336a16 commit 39db1d8

27 files changed

Lines changed: 3363 additions & 7 deletions
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
<script setup lang="ts">
2+
import type {
3+
BattlefieldUnit,
4+
MilitaryUnitType,
5+
MissionPhaseInfo,
6+
} from '~/types/defense-terrain';
7+
8+
defineProps<{
9+
isPlaying: boolean;
10+
speed: number;
11+
progress: number;
12+
missionPhases: MissionPhaseInfo[];
13+
currentPhase: MissionPhaseInfo;
14+
units: BattlefieldUnit[];
15+
activeUnitTypes: Set<MilitaryUnitType>;
16+
}>();
17+
18+
const emit = defineEmits<{
19+
play: [];
20+
pause: [];
21+
reset: [];
22+
setSpeed: [speed: number];
23+
seek: [pct: number];
24+
toggleUnit: [type: MilitaryUnitType];
25+
}>();
26+
27+
const speedOptions = [0.5, 1, 2, 4];
28+
29+
const unitTypeLabels: Record<MilitaryUnitType, string> = {
30+
infantry: 'Infantry',
31+
armor: 'Armor',
32+
patrol: 'Patrol',
33+
recon: 'Recon',
34+
};
35+
36+
const unitTypeIcons: Record<MilitaryUnitType, string> = {
37+
infantry: 'lucide:footprints',
38+
armor: 'lucide:shield',
39+
patrol: 'lucide:scan-eye',
40+
recon: 'lucide:radar',
41+
};
42+
43+
function handleSeek(event: MouseEvent): void {
44+
const target = event.currentTarget as HTMLElement;
45+
const rect = target.getBoundingClientRect();
46+
const pct = ((event.clientX - rect.left) / rect.width) * 100;
47+
emit('seek', pct);
48+
}
49+
</script>
50+
51+
<template>
52+
<div class="space-y-4 p-4">
53+
<div class="space-y-2">
54+
<h3 class="text-sm font-semibold">Mission Phase</h3>
55+
<div class="flex gap-1">
56+
<div
57+
v-for="phase in missionPhases"
58+
:key="phase.phase"
59+
:title="phase.label"
60+
class="flex-1 truncate rounded-md px-1.5 py-1 text-center text-[10px] font-medium transition-colors"
61+
:class="
62+
currentPhase.phase === phase.phase
63+
? 'bg-primary text-primary-foreground'
64+
: 'bg-muted text-muted-foreground'
65+
"
66+
>
67+
{{ phase.label }}
68+
</div>
69+
</div>
70+
</div>
71+
72+
<div class="space-y-2">
73+
<h3 class="text-sm font-semibold">Timeline</h3>
74+
<div
75+
class="h-2 w-full cursor-pointer overflow-hidden rounded-full bg-muted"
76+
@click="handleSeek"
77+
>
78+
<div
79+
class="h-full rounded-full bg-primary transition-all"
80+
:style="{ width: `${progress}%` }"
81+
></div>
82+
</div>
83+
<p class="text-right text-xs text-muted-foreground">
84+
{{ Math.round(progress) }}%
85+
</p>
86+
</div>
87+
88+
<div class="flex items-center justify-center gap-2">
89+
<button
90+
class="rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
91+
title="Reset"
92+
@click="emit('reset')"
93+
>
94+
<Icon name="lucide:rotate-ccw" class="size-4" />
95+
</button>
96+
<button
97+
class="flex size-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
98+
:title="isPlaying ? 'Pause' : 'Play'"
99+
@click="isPlaying ? emit('pause') : emit('play')"
100+
>
101+
<Icon
102+
:name="isPlaying ? 'lucide:pause' : 'lucide:play'"
103+
class="size-4"
104+
/>
105+
</button>
106+
</div>
107+
108+
<div class="space-y-1">
109+
<h4 class="text-xs font-medium text-muted-foreground">Speed</h4>
110+
<div class="flex gap-1">
111+
<button
112+
v-for="s in speedOptions"
113+
:key="s"
114+
class="flex-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
115+
:class="
116+
speed === s
117+
? 'bg-primary text-primary-foreground'
118+
: 'bg-muted text-muted-foreground hover:bg-accent'
119+
"
120+
@click="emit('setSpeed', s)"
121+
>
122+
{{ s }}x
123+
</button>
124+
</div>
125+
</div>
126+
127+
<div class="space-y-2">
128+
<h3 class="text-sm font-semibold">Unit Filters</h3>
129+
<div class="space-y-1">
130+
<button
131+
v-for="unitType in [
132+
'infantry',
133+
'armor',
134+
'patrol',
135+
'recon',
136+
] as MilitaryUnitType[]"
137+
:key="unitType"
138+
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-accent"
139+
:class="
140+
activeUnitTypes.has(unitType)
141+
? 'text-foreground'
142+
: 'text-muted-foreground opacity-50'
143+
"
144+
@click="emit('toggleUnit', unitType)"
145+
>
146+
<Icon :name="unitTypeIcons[unitType]" class="size-3.5" />
147+
<span class="font-medium">{{ unitTypeLabels[unitType] }}</span>
148+
<div
149+
class="ml-auto size-3 rounded-sm border"
150+
:class="
151+
activeUnitTypes.has(unitType)
152+
? 'border-primary bg-primary'
153+
: 'border-border'
154+
"
155+
></div>
156+
</button>
157+
</div>
158+
</div>
159+
160+
<div class="space-y-1.5">
161+
<h3 class="text-sm font-semibold">Active Units</h3>
162+
<div
163+
v-for="unit in units"
164+
:key="unit.id"
165+
class="flex items-center gap-2 text-xs text-muted-foreground"
166+
>
167+
<div
168+
class="size-2 rounded-full"
169+
:style="{ backgroundColor: `rgb(${unit.color.join(',')})` }"
170+
></div>
171+
<span class="font-medium">{{ unit.callsign }}</span>
172+
<span class="ml-auto">{{ unit.strength }} pax</span>
173+
</div>
174+
</div>
175+
</div>
176+
</template>
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<script setup lang="ts">
2+
import type {
3+
BattlefieldPath,
4+
BattlefieldPosition,
5+
BattlefieldUnit,
6+
} from '~/types/defense-terrain';
7+
import { useDeckLayers } from '@geoql/v-maplibre';
8+
9+
const props = defineProps<{
10+
paths: BattlefieldPath[];
11+
currentTime: number;
12+
positions: BattlefieldPosition[];
13+
}>();
14+
15+
const UNITS_MAP: Record<string, BattlefieldUnit> = {
16+
alpha: {
17+
id: 'alpha',
18+
callsign: 'Alpha',
19+
type: 'infantry',
20+
color: [30, 144, 255],
21+
strength: 120,
22+
},
23+
bravo: {
24+
id: 'bravo',
25+
callsign: 'Bravo',
26+
type: 'infantry',
27+
color: [65, 170, 255],
28+
strength: 95,
29+
},
30+
charlie: {
31+
id: 'charlie',
32+
callsign: 'Charlie',
33+
type: 'armor',
34+
color: [255, 165, 0],
35+
strength: 14,
36+
},
37+
delta: {
38+
id: 'delta',
39+
callsign: 'Delta',
40+
type: 'armor',
41+
color: [255, 200, 60],
42+
strength: 12,
43+
},
44+
echo: {
45+
id: 'echo',
46+
callsign: 'Echo',
47+
type: 'patrol',
48+
color: [0, 200, 100],
49+
strength: 30,
50+
},
51+
foxtrot: {
52+
id: 'foxtrot',
53+
callsign: 'Foxtrot',
54+
type: 'recon',
55+
color: [180, 100, 255],
56+
strength: 8,
57+
},
58+
};
59+
60+
function getPath(d: unknown): [number, number][] {
61+
return (d as BattlefieldPath).path;
62+
}
63+
64+
function getTimestamps(d: unknown): number[] {
65+
return (d as BattlefieldPath).timestamps;
66+
}
67+
68+
function getPathColor(d: unknown): [number, number, number] {
69+
const unitId = (d as BattlefieldPath).unitId;
70+
return UNITS_MAP[unitId]?.color ?? [255, 255, 255];
71+
}
72+
73+
function getPositionCoords(d: unknown): [number, number] {
74+
const pos = d as BattlefieldPosition;
75+
return [pos.lng, pos.lat];
76+
}
77+
78+
function getPositionColor(d: unknown): [number, number, number] {
79+
const pos = d as BattlefieldPosition;
80+
return UNITS_MAP[pos.unitId]?.color ?? [255, 255, 255];
81+
}
82+
83+
function getCallsign(d: unknown): string {
84+
const pos = d as BattlefieldPosition;
85+
return UNITS_MAP[pos.unitId]?.callsign ?? '';
86+
}
87+
88+
let TripsLayerClass: typeof import('@deck.gl/geo-layers').TripsLayer | null =
89+
null;
90+
let ScatterplotLayerClass:
91+
| typeof import('@deck.gl/layers').ScatterplotLayer
92+
| null = null;
93+
let TextLayerClass: typeof import('@deck.gl/layers').TextLayer | null = null;
94+
let initialized = false;
95+
96+
const { updateLayer, removeLayer } = useDeckLayers();
97+
98+
async function initLayers(): Promise<void> {
99+
if (initialized) return;
100+
const [geoModule, layersModule] = await Promise.all([
101+
import('@deck.gl/geo-layers'),
102+
import('@deck.gl/layers'),
103+
]);
104+
TripsLayerClass = geoModule.TripsLayer;
105+
ScatterplotLayerClass = layersModule.ScatterplotLayer;
106+
TextLayerClass = layersModule.TextLayer;
107+
initialized = true;
108+
syncLayers();
109+
}
110+
111+
const ALL_UNIT_IDS = [
112+
'alpha',
113+
'bravo',
114+
'charlie',
115+
'delta',
116+
'echo',
117+
'foxtrot',
118+
];
119+
120+
function syncLayers(): void {
121+
if (!TripsLayerClass || !ScatterplotLayerClass || !TextLayerClass) return;
122+
123+
const activeIds = new Set(props.paths.map((p) => p.unitId));
124+
for (const uid of ALL_UNIT_IDS) {
125+
if (!activeIds.has(uid)) {
126+
removeLayer(`trail-${uid}`);
127+
}
128+
}
129+
130+
for (const pathData of props.paths) {
131+
const unit = UNITS_MAP[pathData.unitId];
132+
if (!unit) continue;
133+
134+
const trail = new TripsLayerClass({
135+
id: `trail-${pathData.unitId}`,
136+
data: [pathData],
137+
getPath,
138+
getTimestamps,
139+
getColor: getPathColor,
140+
currentTime: props.currentTime,
141+
trailLength: 60,
142+
fadeTrail: true,
143+
widthMinPixels: 4,
144+
capRounded: true,
145+
jointRounded: true,
146+
opacity: 0.85,
147+
});
148+
updateLayer(`trail-${pathData.unitId}`, trail);
149+
}
150+
151+
const scatter = new ScatterplotLayerClass({
152+
id: 'unit-positions',
153+
data: props.positions,
154+
getPosition: getPositionCoords,
155+
getFillColor: getPositionColor,
156+
getRadius: 200,
157+
radiusMinPixels: 6,
158+
radiusMaxPixels: 20,
159+
opacity: 0.9,
160+
stroked: true,
161+
getLineColor: [255, 255, 255] as [number, number, number],
162+
lineWidthMinPixels: 2,
163+
});
164+
updateLayer('unit-positions', scatter);
165+
166+
const labels = new TextLayerClass({
167+
id: 'unit-labels',
168+
data: props.positions,
169+
getPosition: getPositionCoords,
170+
getText: getCallsign,
171+
getSize: 14,
172+
getColor: [255, 255, 255, 230] as [number, number, number, number],
173+
getAngle: 0,
174+
getTextAnchor: 'start' as const,
175+
getAlignmentBaseline: 'center' as const,
176+
getPixelOffset: [12, 0] as [number, number],
177+
fontFamily: 'monospace',
178+
billboard: true,
179+
outlineWidth: 3,
180+
outlineColor: [0, 0, 0, 200] as [number, number, number, number],
181+
});
182+
updateLayer('unit-labels', labels);
183+
}
184+
185+
watch(
186+
() => [props.paths, props.currentTime, props.positions],
187+
() => syncLayers(),
188+
{ deep: true },
189+
);
190+
191+
onMounted(() => {
192+
initLayers();
193+
});
194+
195+
onUnmounted(() => {
196+
for (const p of props.paths) {
197+
removeLayer(`trail-${p.unitId}`);
198+
}
199+
removeLayer('unit-positions');
200+
removeLayer('unit-labels');
201+
});
202+
</script>
203+
204+
<template>
205+
<slot></slot>
206+
</template>

0 commit comments

Comments
 (0)