diff --git a/drizzle/0005_wise_starfox.sql b/drizzle/0005_wise_starfox.sql
new file mode 100644
index 0000000..0241f2a
--- /dev/null
+++ b/drizzle/0005_wise_starfox.sql
@@ -0,0 +1,21 @@
+CREATE TABLE IF NOT EXISTS "airspace_overlay_components" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "group_id" uuid NOT NULL,
+ "name" text,
+ "color" text,
+ "geojson" jsonb,
+ "settings" jsonb
+);
+--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS "airspace_overlay_groups" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "name" text,
+ "is_published" boolean DEFAULT false NOT NULL,
+ "created_at" timestamp DEFAULT now()
+);
+--> statement-breakpoint
+DO $$ BEGIN
+ ALTER TABLE "airspace_overlay_components" ADD CONSTRAINT "airspace_overlay_components_group_id_airspace_overlay_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."airspace_overlay_groups"("id") ON DELETE cascade ON UPDATE no action;
+EXCEPTION
+ WHEN duplicate_object THEN null;
+END $$;
diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json
new file mode 100644
index 0000000..829dfbc
--- /dev/null
+++ b/drizzle/meta/0005_snapshot.json
@@ -0,0 +1,831 @@
+{
+ "id": "45817721-3126-4dcc-b602-64fe3d7e3004",
+ "prevId": "78b4ec70-ab59-48e0-8cf9-c4d3d570b810",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.adar_records": {
+ "name": "adar_records",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "adar_id": {
+ "name": "adar_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upper_altitude": {
+ "name": "upper_altitude",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lower_altitude": {
+ "name": "lower_altitude",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auto_route_limit": {
+ "name": "auto_route_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "route_string": {
+ "name": "route_string",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "protected_area_overwrite": {
+ "name": "protected_area_overwrite",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "star_id": {
+ "name": "star_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dp_id": {
+ "name": "dp_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "route_fixes": {
+ "name": "route_fixes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "arrival_airports": {
+ "name": "arrival_airports",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "departure_airports": {
+ "name": "departure_airports",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_comment": {
+ "name": "user_comment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "adar_records_adar_id_unique": {
+ "name": "adar_records_adar_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "adar_id"
+ ]
+ }
+ }
+ },
+ "public.aircraft": {
+ "name": "aircraft",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "class": {
+ "name": "class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "number_of_engines": {
+ "name": "number_of_engines",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "engine_type": {
+ "name": "engine_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "manufacturer": {
+ "name": "manufacturer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "aircraft_code_index": {
+ "name": "aircraft_code_index",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.airlines": {
+ "name": "airlines",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company": {
+ "name": "company",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "country": {
+ "name": "country",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "telephony": {
+ "name": "telephony",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "airline_code_index": {
+ "name": "airline_code_index",
+ "columns": [
+ {
+ "expression": "code",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.airspace_overlay_components": {
+ "name": "airspace_overlay_components",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "geojson": {
+ "name": "geojson",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "airspace_overlay_components_group_id_airspace_overlay_groups_id_fk": {
+ "name": "airspace_overlay_components_group_id_airspace_overlay_groups_id_fk",
+ "tableFrom": "airspace_overlay_components",
+ "tableTo": "airspace_overlay_groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.airspace_overlay_groups": {
+ "name": "airspace_overlay_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.airspace_static_element_components": {
+ "name": "airspace_static_element_components",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "geojson": {
+ "name": "geojson",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "settings": {
+ "name": "settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "airspace_static_element_components_group_id_airspace_static_element_groups_id_fk": {
+ "name": "airspace_static_element_components_group_id_airspace_static_element_groups_id_fk",
+ "tableFrom": "airspace_static_element_components",
+ "tableTo": "airspace_static_element_groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.airspace_static_element_groups": {
+ "name": "airspace_static_element_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.area_metadata": {
+ "name": "area_metadata",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "short": {
+ "name": "short",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "long": {
+ "name": "long",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tag": {
+ "name": "tag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "geojson": {
+ "name": "geojson",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "frequency": {
+ "name": "frequency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.auth_user": {
+ "name": "auth_user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "first_name": {
+ "name": "first_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_name": {
+ "name": "last_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_admin": {
+ "name": "is_admin",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.restrictions": {
+ "name": "restrictions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "airport": {
+ "name": "airport",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "route": {
+ "name": "route",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from": {
+ "name": "from",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "to": {
+ "name": "to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "restriction": {
+ "name": "restriction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'0'"
+ },
+ "valid_at": {
+ "name": "valid_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "valid_until": {
+ "name": "valid_until",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.split_group_areas": {
+ "name": "split_group_areas",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "area_id": {
+ "name": "area_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "split_group_areas_group_id_split_groups_id_fk": {
+ "name": "split_group_areas_group_id_split_groups_id_fk",
+ "tableFrom": "split_group_areas",
+ "tableTo": "split_groups",
+ "columnsFrom": [
+ "group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "split_group_areas_area_id_area_metadata_id_fk": {
+ "name": "split_group_areas_area_id_area_metadata_id_fk",
+ "tableFrom": "split_group_areas",
+ "tableTo": "area_metadata",
+ "columnsFrom": [
+ "area_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.split_groups": {
+ "name": "split_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "split_id": {
+ "name": "split_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "split_groups_split_id_splits_id_fk": {
+ "name": "split_groups_split_id_splits_id_fk",
+ "tableFrom": "split_groups",
+ "tableTo": "splits",
+ "columnsFrom": [
+ "split_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.splits": {
+ "name": "splits",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "public.user_session": {
+ "name": "user_session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 5ceec70..9daca86 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -36,6 +36,13 @@
"when": 1738556826419,
"tag": "0004_zippy_zarek",
"breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "7",
+ "when": 1738620021149,
+ "tag": "0005_wise_starfox",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/lib/components/form/MultiSelect.svelte b/src/lib/components/form/MultiSelect.svelte
new file mode 100644
index 0000000..162d997
--- /dev/null
+++ b/src/lib/components/form/MultiSelect.svelte
@@ -0,0 +1,97 @@
+
+
+
+
(isOpen = !isOpen)}
+ >
+
+ {#if selected.length === 0}
+ {placeholder}
+ {:else}
+ {placeholder} ({selected.length})
+ {/if}
+
+
+
+
+ {#if isOpen}
+
+
+
+
+
+ {#each filteredOptions as option}
+ toggle(option.id)}
+ >
+
+ {#if option.icon}
+
+ {/if}
+ {option.label}
+
+ {#if selected.includes(option.id)}
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
diff --git a/src/lib/components/map/AirspaceMap.svelte b/src/lib/components/map/AirspaceMap.svelte
index 4036e7c..5ecb031 100644
--- a/src/lib/components/map/AirspaceMap.svelte
+++ b/src/lib/components/map/AirspaceMap.svelte
@@ -14,6 +14,7 @@
import MdiIcon from '../MdiIcon.svelte';
import Legend from './Legend.svelte';
import SplitName from '$lib/components/splits/SplitName.svelte';
+ import MultiSelect from '$lib/components/form/MultiSelect.svelte';
interface Area {
id: string;
@@ -65,9 +66,26 @@
}[];
}
- let { splits = [], staticElementGroups = [] } = $props<{
+ interface OverlayGroup {
+ id: string;
+ name: string;
+ isPublished: boolean;
+ components: {
+ id: string;
+ name: string;
+ geojson: object;
+ color: string;
+ }[];
+ }
+
+ let {
+ splits = [],
+ staticElementGroups = [],
+ overlayGroups = []
+ } = $props<{
splits?: Split[];
staticElementGroups: StaticElementGroup[];
+ overlayGroups: OverlayGroup[];
}>();
let L: typeof import('leaflet') | undefined;
@@ -80,8 +98,9 @@
let combinedSplitLayer: LayerGroup | undefined;
let controllerLayer: LayerGroup | undefined;
let staticElementLayer: LayerGroup | undefined;
+ let overlayLayer: LayerGroup | undefined;
- // Use Session Storage to persist settings - directly use the returned value
+ // Use Session Storage to persist settings
let settings = $state(
useSessionStorage('mapSettings', {
showTiles: true,
@@ -104,6 +123,9 @@
useSessionStorage('mapStaticElements', {})
);
+ // state for tracking overlay visibility
+ let showOverlays = $state>(useSessionStorage('mapOverlays', {}));
+
let selectedSplit = $state(null);
let isDropdownOpen = $state(false);
@@ -193,8 +215,10 @@
initializeLayerGroups();
initializeThemeObserver();
initializeStaticElements();
+ initializeOverlays();
await initializeSplits();
await renderStaticElements(showStaticElements);
+ await renderOverlays(showOverlays);
}
});
@@ -207,6 +231,15 @@
});
}
+ function initializeOverlays() {
+ // Add each overlay to the toggle state
+ overlayGroups.forEach((g: OverlayGroup) => {
+ if (!Object.keys(showOverlays).includes(g.id)) {
+ showOverlays[g.id] = false;
+ }
+ });
+ }
+
function initializeTileLayers() {
const lightTiles = L!.tileLayer(
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
@@ -250,6 +283,7 @@
combinedSplitLayer = L!.layerGroup().addTo(map!);
controllerLayer = L!.layerGroup().addTo(map!);
staticElementLayer = L!.layerGroup().addTo(map!);
+ overlayLayer = L!.layerGroup().addTo(map!);
}
function initializeThemeObserver() {
@@ -451,6 +485,81 @@
renderStaticElements({ ...showStaticElements });
});
+ async function renderDynamicGeoJson(layer: any, component: any) {
+ const geojsonData = component.geojson as GeoJSON.FeatureCollection;
+
+ // Lines Layer
+ const lineFeatures = geojsonData.features.filter(
+ (f) => f.geometry.type === 'LineString' || f.geometry.type === 'MultiLineString'
+ );
+ if (lineFeatures.length > 0) {
+ const lineLayer = L!.geoJSON(
+ {
+ type: 'FeatureCollection',
+ features: lineFeatures
+ },
+ {
+ style: {
+ color: component.color,
+ weight: component.settings?.weight ?? 1,
+ opacity: component.settings?.opacity ?? 0.8,
+ lineCap: component.settings?.lineCap ?? 'round',
+ lineJoin: component.settings?.lineJoin ?? 'round'
+ }
+ }
+ );
+ lineLayer.addTo(layer!);
+ }
+
+ // Points Layer
+ const pointFeatures = geojsonData.features.filter((f) => f.geometry.type === 'Point');
+ if (pointFeatures.length > 0) {
+ L!
+ .geoJSON(
+ {
+ type: 'FeatureCollection',
+ features: pointFeatures
+ },
+ {
+ pointToLayer: (_feature, latlng) => {
+ return L!.circleMarker(latlng, {
+ radius: component.settings?.radius ?? 2,
+ fillColor: component.color,
+ color: component.color,
+ weight: component.settings?.weight ?? 1,
+ opacity: component.settings?.opacity ?? 0.8,
+ fillOpacity: component.settings?.fillOpacity ?? 0.8
+ });
+ }
+ }
+ )
+ .addTo(layer!);
+ }
+
+ // Polygons
+ const polygonFeatures = geojsonData.features.filter((f) => f.geometry.type === 'Polygon');
+ if (polygonFeatures.length > 0) {
+ L!
+ .geoJSON(
+ {
+ type: 'FeatureCollection',
+ features: polygonFeatures
+ },
+ {
+ style: {
+ color: component.color,
+ weight: component.settings?.weight ?? 1,
+ opacity: component.settings?.opacity ?? 0.8,
+ fillOpacity: component.settings?.fillOpacity ?? 0.8,
+ lineCap: component.settings?.lineCap ?? 'round',
+ lineJoin: component.settings?.lineJoin ?? 'round'
+ }
+ }
+ )
+ .addTo(layer!);
+ }
+ }
+
async function renderStaticElements(visibility: Record) {
if (!L || !map || !staticElementLayer) return;
@@ -459,78 +568,21 @@
staticElementGroups.forEach((group: StaticElementGroup) => {
if (visibility[group.id]) {
group.components.forEach((component) => {
- const geojsonData = component.geojson as GeoJSON.FeatureCollection;
-
- // Lines Layer
- const lineFeatures = geojsonData.features.filter(
- (f) => f.geometry.type === 'LineString' || f.geometry.type === 'MultiLineString'
- );
- if (lineFeatures.length > 0) {
- const lineLayer = L!.geoJSON(
- {
- type: 'FeatureCollection',
- features: lineFeatures
- },
- {
- style: {
- color: component.color,
- weight: component.settings?.weight ?? 1,
- opacity: component.settings?.opacity ?? 0.8,
- lineCap: component.settings?.lineCap ?? 'round',
- lineJoin: component.settings?.lineJoin ?? 'round'
- }
- }
- );
- lineLayer.addTo(staticElementLayer!);
- }
+ renderDynamicGeoJson(staticElementLayer, component);
+ });
+ }
+ });
+ }
- // Points Layer
- const pointFeatures = geojsonData.features.filter((f) => f.geometry.type === 'Point');
- if (pointFeatures.length > 0) {
- L!
- .geoJSON(
- {
- type: 'FeatureCollection',
- features: pointFeatures
- },
- {
- pointToLayer: (_feature, latlng) => {
- return L!.circleMarker(latlng, {
- radius: component.settings?.radius ?? 2,
- fillColor: component.color,
- color: component.color,
- weight: component.settings?.weight ?? 1,
- opacity: component.settings?.opacity ?? 0.8,
- fillOpacity: component.settings?.fillOpacity ?? 0.8
- });
- }
- }
- )
- .addTo(staticElementLayer!);
- }
+ async function renderOverlays(visibility: Record) {
+ if (!L || !map || !overlayLayer) return;
- // Polygons
- const polygonFeatures = geojsonData.features.filter((f) => f.geometry.type === 'Polygon');
- if (polygonFeatures.length > 0) {
- L!
- .geoJSON(
- {
- type: 'FeatureCollection',
- features: polygonFeatures
- },
- {
- style: {
- color: component.color,
- weight: component.settings?.weight ?? 1,
- opacity: component.settings?.opacity ?? 0.8,
- fillOpacity: component.settings?.fillOpacity ?? 0.8,
- lineCap: component.settings?.lineCap ?? 'round',
- lineJoin: component.settings?.lineJoin ?? 'round'
- }
- }
- )
- .addTo(staticElementLayer!);
- }
+ overlayLayer.clearLayers();
+
+ overlayGroups.forEach((group: OverlayGroup) => {
+ if (visibility[group.id]) {
+ group.components.forEach((component) => {
+ renderDynamicGeoJson(overlayLayer, component);
});
}
});
@@ -652,14 +704,6 @@
}
});
- function handleCreateClick() {
- goto('/admin/splits/create');
- }
-
- function handleEditClick() {
- goto(`/admin/splits/${selectedSplit}/edit`);
- }
-
function getTagMenuActions() {
return getTagsAndColors().map((tag) => ({
text: tag.tag.toUpperCase(),
@@ -710,6 +754,16 @@
return baseActions;
});
+
+ $effect(() => {
+ renderOverlays({ ...showOverlays });
+ });
+
+ function handleOverlayChange(selectedIds: string[]) {
+ Object.keys(showOverlays).forEach((k) => {
+ showOverlays[k] = selectedIds.includes(k);
+ });
+ }
@@ -862,8 +916,24 @@
class:opacity-0={!showMobileControls}
>
-
+
+
+ {#if overlayGroups.length > 0}
+ g.isPublished || getUserInfo()?.isAdmin)
+ .map((g: OverlayGroup) => ({
+ id: g.id,
+ label: g.name
+ }))}
+ selected={Object.entries(showOverlays)
+ .filter(([_, visible]) => visible)
+ .map(([id]) => id)}
+ onChange={handleOverlayChange}
+ placeholder="Configure Overlays"
+ />
+ {/if}
@@ -887,9 +957,27 @@
-
+
{#if splits.length > 0 && getTagsAndColors().length > 0}
-
+
+
+
+ {#if overlayGroups.length > 0}
+ g.isPublished || getUserInfo()?.isAdmin)
+ .map((g: OverlayGroup) => ({
+ id: g.id,
+ label: g.name
+ }))}
+ selected={Object.entries(showOverlays)
+ .filter(([_, visible]) => visible)
+ .map(([id]) => id)}
+ onChange={handleOverlayChange}
+ placeholder="Configure Overlays"
+ />
+ {/if}
+
{/if}
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 52cb234..7d2967a 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -104,6 +104,24 @@ export const airspaceStaticElementComponentsTable = pgTable('airspace_static_ele
settings: jsonb('settings')
});
+export const airspaceOverlayGroupsTable = pgTable('airspace_overlay_groups', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ name: text('name'),
+ isPublished: boolean('is_published').notNull().default(false),
+ createdAt: timestamp('created_at').defaultNow()
+});
+
+export const airspaceOverlayComponentsTable = pgTable('airspace_overlay_components', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ groupId: uuid('group_id')
+ .references(() => airspaceOverlayGroupsTable.id, { onDelete: 'cascade' })
+ .notNull(),
+ name: text('name'),
+ color: text('color'),
+ geojson: jsonb('geojson'),
+ settings: jsonb('settings')
+});
+
// Airline Codes
// Company Country Telephony 3Ltr
export const airlinesTable = pgTable(
diff --git a/src/routes/(protected)/admin/AdminMenuButton.svelte b/src/routes/(protected)/admin/AdminMenuButton.svelte
index 7635997..f119983 100644
--- a/src/routes/(protected)/admin/AdminMenuButton.svelte
+++ b/src/routes/(protected)/admin/AdminMenuButton.svelte
@@ -1,23 +1,36 @@
-
- {label}
+
+ {label}
diff --git a/src/routes/(protected)/admin/airspace/+page.server.ts b/src/routes/(protected)/admin/airspace/+page.server.ts
index e791342..4f4e6f9 100644
--- a/src/routes/(protected)/admin/airspace/+page.server.ts
+++ b/src/routes/(protected)/admin/airspace/+page.server.ts
@@ -1,6 +1,8 @@
import {
airspaceStaticElementGroupsTable,
- airspaceStaticElementComponentsTable
+ airspaceStaticElementComponentsTable,
+ airspaceOverlayGroupsTable,
+ airspaceOverlayComponentsTable
} from '$lib/db/schema';
import { db } from '$lib/server/db';
import { redirect } from '@sveltejs/kit';
@@ -33,7 +35,30 @@ const staticElementGroupSchema = object({
isPublished: optional(boolean())
});
-const defaults = {
+const overlayComponentSchema = object({
+ id: optional(string()),
+ groupId: optional(string()),
+ name: nonempty(string()),
+ color: nonempty(string()),
+ geojson: any(),
+ settings: object({
+ weight: optional(number()),
+ opacity: optional(number()),
+ lineCap: optional(string()),
+ lineJoin: optional(string()),
+ radius: optional(number()),
+ fillOpacity: optional(number())
+ })
+});
+
+const overlayGroupSchema = object({
+ id: optional(string()),
+ name: nonempty(string()),
+ components: array(overlayComponentSchema),
+ isPublished: optional(boolean())
+});
+
+const staticDefaults = {
id: '',
name: '',
icon: '',
@@ -41,6 +66,13 @@ const defaults = {
isPublished: false
};
+const overlayDefaults = {
+ id: '',
+ name: '',
+ components: [],
+ isPublished: false
+};
+
const defaultSettings = {
weight: 1,
opacity: 0.8,
@@ -51,9 +83,20 @@ const defaultSettings = {
};
export async function load() {
- const form = await superValidate(superstruct(staticElementGroupSchema, { defaults }));
+ const staticForm = await superValidate(
+ superstruct(staticElementGroupSchema, { defaults: staticDefaults }),
+ {
+ id: 'static'
+ }
+ );
+ const overlayForm = await superValidate(
+ superstruct(overlayGroupSchema, { defaults: overlayDefaults }),
+ {
+ id: 'overlay'
+ }
+ );
- const results = await db
+ const staticResults = await db
.select()
.from(airspaceStaticElementGroupsTable)
.leftJoin(
@@ -62,9 +105,18 @@ export async function load() {
)
.orderBy(airspaceStaticElementGroupsTable.createdAt);
+ const overlayResults = await db
+ .select()
+ .from(airspaceOverlayGroupsTable)
+ .leftJoin(
+ airspaceOverlayComponentsTable,
+ eq(airspaceOverlayGroupsTable.id, airspaceOverlayComponentsTable.groupId)
+ )
+ .orderBy(airspaceOverlayGroupsTable.createdAt);
+
// Group results by static element groups and their components
const staticElements = Object.values(
- results.reduce((acc: Record
, row) => {
+ staticResults.reduce((acc: Record, row) => {
const group = row.airspace_static_element_groups;
if (!acc[group.id]) {
@@ -82,11 +134,31 @@ export async function load() {
}, {})
);
- return { staticElements, form };
+ // Group results by overlay groups and their components
+ const overlayGroups = Object.values(
+ overlayResults.reduce((acc: Record, row) => {
+ const group = row.airspace_overlay_groups;
+
+ if (!acc[group.id]) {
+ acc[group.id] = {
+ ...group,
+ components: []
+ };
+ }
+
+ if (row.airspace_overlay_components) {
+ acc[group.id].components.push(row.airspace_overlay_components);
+ }
+
+ return acc;
+ }, {})
+ );
+
+ return { staticElements, overlayGroups, staticForm, overlayForm };
}
export const actions = {
- delete: async ({ request }) => {
+ deleteStatic: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
@@ -100,7 +172,19 @@ export const actions = {
return { success: true };
},
- togglePublish: async ({ request }) => {
+ deleteOverlay: async ({ request }) => {
+ const formData = await request.formData();
+ const id = formData.get('id') as string;
+
+ if (!id) {
+ return { success: false, error: 'No ID provided for deletion.' };
+ }
+
+ await db.delete(airspaceOverlayGroupsTable).where(eq(airspaceOverlayGroupsTable.id, id));
+ return { success: true };
+ },
+
+ togglePublishStatic: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id') as string;
const publish = formData.get('publish') === 'true';
@@ -122,9 +206,34 @@ export const actions = {
}
},
+ togglePublishOverlay: async ({ request }) => {
+ const formData = await request.formData();
+ const id = formData.get('id') as string;
+ const publish = formData.get('publish') === 'true';
+
+ if (!id) {
+ return fail(400, { error: 'Overlay Group ID is required' });
+ }
+
+ try {
+ await db
+ .update(airspaceOverlayGroupsTable)
+ .set({ isPublished: publish })
+ .where(eq(airspaceOverlayGroupsTable.id, id));
+
+ return { success: true };
+ } catch (e) {
+ console.error('Error updating overlay group publish status:', e);
+ return fail(500, { error: 'Failed to update overlay group publish status' });
+ }
+ },
+
addUpdateStaticElements: async ({ request }) => {
const formData = await request.formData();
- const form = await superValidate(formData, superstruct(staticElementGroupSchema, { defaults }));
+ const form = await superValidate(
+ formData,
+ superstruct(staticElementGroupSchema, { defaults: staticDefaults })
+ );
if (!form.valid) {
return fail(400, { form });
@@ -182,5 +291,68 @@ export const actions = {
console.error(err);
return fail(500, { form, error: 'Failed to add Static Element Group.' });
}
+ },
+
+ addUpdateOverlays: async ({ request }) => {
+ const formData = await request.formData();
+ const form = await superValidate(
+ formData,
+ superstruct(overlayGroupSchema, { defaults: overlayDefaults })
+ );
+
+ if (!form.valid) {
+ return fail(400, { form });
+ }
+
+ try {
+ if (form.data.id) {
+ // Update
+ await db.transaction(async (tx) => {
+ // Update Group Information
+ await tx
+ .update(airspaceOverlayGroupsTable)
+ .set({
+ name: form.data.name,
+ isPublished: form.data.isPublished
+ })
+ .where(eq(airspaceOverlayGroupsTable.id, form.data.id!));
+ // Delete all components and re-add them instead of trying to merge
+ await tx
+ .delete(airspaceOverlayComponentsTable)
+ .where(eq(airspaceOverlayComponentsTable.groupId, form.data.id!));
+
+ for (const component of form.data.components) {
+ await tx.insert(airspaceOverlayComponentsTable).values({
+ ...component,
+ groupId: form.data.id!
+ });
+ }
+ });
+ return message(form, 'Overlay updated successfully!');
+ } else {
+ // Insert
+ await db.transaction(async (tx) => {
+ // Update Group Information
+ const [group] = await tx
+ .insert(airspaceOverlayGroupsTable)
+ .values({
+ name: form.data.name,
+ isPublished: form.data.isPublished
+ })
+ .returning();
+
+ for (const component of form.data.components) {
+ await tx.insert(airspaceOverlayComponentsTable).values({
+ ...component,
+ groupId: group.id
+ });
+ }
+ });
+ return message(form, 'Overlay added successfully!');
+ }
+ } catch (err) {
+ console.error(err);
+ return fail(500, { form, error: 'Failed to add Overlay Group.' });
+ }
}
};
diff --git a/src/routes/(protected)/admin/airspace/+page.svelte b/src/routes/(protected)/admin/airspace/+page.svelte
index 467f532..3e2689a 100644
--- a/src/routes/(protected)/admin/airspace/+page.svelte
+++ b/src/routes/(protected)/admin/airspace/+page.svelte
@@ -2,38 +2,36 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
import ConfirmationModal from '$lib/ConfirmationModal.svelte';
import AddUpdateStaticElementForm from './AddUpdateStaticElementForm.svelte';
+ import AddUpdateOverlayForm from './AddUpdateOverlayForm.svelte';
+ import EmptyState from '$lib/components/EmptyState.svelte';
+ import { enhance } from '$app/forms';
let { data } = $props();
- let addUpdateStaticElementForm: ReturnType;
- let confirmModal: ReturnType;
-
- // Search query for filtering elements
+ let confirmDeleteStaticElementModal: ReturnType;
+ let confirmDeleteOverlayModal: ReturnType;
+ let staticElementForm: ReturnType;
+ let overlayForm: ReturnType;
let searchQuery = $state('');
- type StaticElementComponent = {
- id: string;
- name: string;
- color: string;
- geojson: any;
- settings: {
- weight?: number;
- opacity?: number;
- lineCap?: string;
- lineJoin?: string;
- radius?: number;
- fillOpacity?: number;
- };
- };
+ let filteredStaticElements = $derived.by(() => {
+ if (!searchQuery) return data.staticElements;
+ const query = searchQuery.toLowerCase();
+ return data.staticElements.filter(
+ (element) =>
+ element.name.toLowerCase().includes(query) ||
+ element.components.some((comp: { name: string }) => comp.name.toLowerCase().includes(query))
+ );
+ });
- // Filtered elements derived from the search query
- let filteredElements = $derived.by(() => {
- return data.staticElements.filter((element) => {
- if (searchQuery !== '') {
- return element.name?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false;
- }
- return true;
- });
+ let filteredOverlays = $derived.by(() => {
+ if (!searchQuery) return data.overlayGroups;
+ const query = searchQuery.toLowerCase();
+ return data.overlayGroups.filter(
+ (overlay) =>
+ overlay.name.toLowerCase().includes(query) ||
+ overlay.components.some((comp: { name: string }) => comp.name.toLowerCase().includes(query))
+ );
});
@@ -41,108 +39,254 @@
ICT - Airspace Management
-
+
-
-
+
+
+
Airspace Management
+
+ Add and update map elements for display on the Airspace page. Configure static elements and
+ overlays to enhance controller visualization and situational awareness.
+
+
+
+
+
+
+
+
+
+
+
+
Static Elements
+
+ Static Elements are foundational map features that provide essential context for all
+ controllers. These include permanent features like airways, boundary lines, and fixed
+ geographical markers that are relevant across all positions and operations.
+
+
+
+
+
+
+
+
+
+
+
+
Overlays
+
+ Overlays are toggleable map elements that provide additional context for specific
+ operations. Common examples include SIDs (Standard Instrument Departures), STARs
+ (Standard Terminal Arrival Routes), and other procedural information that controllers
+ can enable based on their current needs.
+
+
+
+
+
+
+
+
+
+
+
+
+
staticElementForm.create()}
+ class="flex items-center justify-center gap-2 rounded-md bg-action-success px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:bg-green-600 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-green-500/20"
+ >
+
+ Add Static Element
+
addUpdateStaticElementForm.create()}
+ type="button"
+ onclick={() => overlayForm.create()}
+ class="flex items-center justify-center gap-2 rounded-md bg-action-success px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:bg-green-600 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-green-500/20"
>
- Add Element Group
+ Add Overlay
-
+
+
+
Static Elements
-
- {#each filteredElements as element}
-
-
-
-
- {element.name ?? 'Unnamed Element'}
-
-
- addUpdateStaticElementForm.edit(element)}
- >
-
-
- confirmModal.prompt({ id: element.id })}
- >
-
-
-
-
-
-
-
+ {#if data.staticElements.length === 0}
+
+ {:else}
+
+ {#each filteredStaticElements as element}
- Components ({element.components.length})
-
-
- {#each element.components.filter((c: StaticElementComponent | null): c is StaticElementComponent => c !== null) as component}
-
-
{component.name ?? 'Unnamed Component'}
+
+
+
+
+
{element.name}
+
+
+ staticElementForm.edit(element)}
+ class="rounded-md bg-surface-secondary p-2 text-content-secondary transition-all hover:bg-accent hover:text-white focus:outline-none focus:ring-2 focus:ring-accent/20 dark:bg-surface-dark-secondary dark:text-content-dark-secondary dark:hover:bg-accent-dark"
+ >
+
+
+ confirmDeleteStaticElementModal.prompt({ id: element.id })}
+ >
+
+
+
- {/each}
+
+ {#each element.components as component}
+
+
+ {component.name}
+
+ {/each}
+
+
+
+
+
-
+ {/each}
+
+ {/if}
+
-
-
-
- {#if element.icon}
-
- {/if}
- {element.icon ?? 'No Icon'}
-
+
+
+
Overlays
-
-
+ {#if data.overlayGroups.length === 0}
+
+ {:else}
+
+ {#each filteredOverlays as overlay}
+
+
+
+
{overlay.name}
+
+ overlayForm.edit(overlay)}
+ class="rounded-md bg-surface-secondary p-2 text-content-secondary transition-all hover:bg-accent hover:text-white focus:outline-none focus:ring-2 focus:ring-accent/20 dark:bg-surface-dark-secondary dark:text-content-dark-secondary dark:hover:bg-accent-dark"
+ >
+
+
+ confirmDeleteOverlayModal.prompt({ id: overlay.id })}
+ >
+
+
+
+
+
+ {#each overlay.components as component}
+
+
+ {component.name}
+
+ {/each}
+
+
+
+
+
+
+ {/each}
- {/each}
+ {/if}
+
+
+
diff --git a/src/routes/(protected)/admin/airspace/AddUpdateOverlayForm.svelte b/src/routes/(protected)/admin/airspace/AddUpdateOverlayForm.svelte
new file mode 100644
index 0000000..c3c4d89
--- /dev/null
+++ b/src/routes/(protected)/admin/airspace/AddUpdateOverlayForm.svelte
@@ -0,0 +1,371 @@
+
+
+
+
+
+
+
diff --git a/src/routes/(protected)/admin/airspace/AddUpdateStaticElementForm.svelte b/src/routes/(protected)/admin/airspace/AddUpdateStaticElementForm.svelte
index a78e3a2..52a8585 100644
--- a/src/routes/(protected)/admin/airspace/AddUpdateStaticElementForm.svelte
+++ b/src/routes/(protected)/admin/airspace/AddUpdateStaticElementForm.svelte
@@ -4,8 +4,9 @@
import MdiIcon from '$lib/components/MdiIcon.svelte';
import MdiIconSelect from '$lib/components/MdiIconSelect.svelte';
import { superForm } from 'sveltekit-superforms';
+ import type { SuperValidated } from 'sveltekit-superforms';
- let { data }: { data: any } = $props();
+ let { data }: { data: SuperValidated
} = $props();
let mode = $state('ADD');
let modal: ReturnType;
@@ -16,19 +17,19 @@
color: string;
geojson: any;
settings: {
- weight: number;
- opacity: number;
- lineCap: string;
- lineJoin: string;
- radius: number;
- fillOpacity: number;
+ weight?: number;
+ opacity?: number;
+ lineCap?: string;
+ lineJoin?: string;
+ radius?: number;
+ fillOpacity?: number;
};
};
let components: StaticElement[] = $state([]);
let activeElement: StaticElement | null = $state(null);
- const { form, errors, constraints, message, enhance, reset } = superForm(data.form, {
+ const { form, errors, constraints, message, enhance, reset } = superForm(data, {
dataType: 'json',
onUpdated({ form }) {
if (form.valid) {
@@ -265,7 +266,7 @@
class="flex items-center gap-x-1 rounded-md bg-surface-secondary px-2 py-1 text-xs text-content hover:bg-surface-tertiary dark:bg-surface-dark-secondary dark:text-content-dark dark:hover:bg-surface-dark-tertiary"
onclick={() => {
if (activeElement) {
- activeElement.settings = { ...defaultSettings };
+ activeElement.settings = defaultSettings;
activeElement = null;
}
}}
diff --git a/src/routes/(protected)/admin/areas/+page.svelte b/src/routes/(protected)/admin/areas/+page.svelte
index b344945..74e0153 100644
--- a/src/routes/(protected)/admin/areas/+page.svelte
+++ b/src/routes/(protected)/admin/areas/+page.svelte
@@ -36,108 +36,123 @@
message="Are you sure you want to delete this area? This action cannot be undone."
/>
-
-
-
Area Management
+
+
+
+
Areas Management
+
+ Define and manage controllable airspace areas. These areas form the building blocks for splits
+ and determine the scope of controller responsibility.
+
+
+
+
+
-
-
-
-
-
-
-
-
ID
-
Short Name
-
Long Name
-
Category
-
Tag
-
Color
-
Actions
-
-
-
-
+
+
+
- {#each filteredAreas as area}
-
-
- {area.id}
-
-
- Short Name:{' '}
- {area.short}
-
-
- Long Name:{' '}
- {area.long}
-
-
- Category:{' '}
- {area.category}
-
-
- Tag:{' '}
- {area.tag || '—'}
-
-
-
-
addUpdateAreaForm.edit(area)}
- >
-
-
-
confirmModal.prompt({ id: area.id })}
- class="rounded-md bg-action-danger px-3 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-red-600 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-red-500/20"
- >
-
-
+
ID
+
Short Name
+
Long Name
+
Category
+
Tag
+
Color
+
Actions
+
+
+
+
+
+ {#each filteredAreas as area}
+
+
+ {area.id}
+
+
+ Short Name:{' '}
+ {area.short}
+
+
+ Long Name:{' '}
+ {area.long}
+
+
+ Category:{' '}
+ {area.category}
+
+
+ Tag:{' '}
+ {area.tag || '—'}
+
+
+
+ addUpdateAreaForm.edit(area)}
+ >
+
+
+ confirmModal.prompt({ id: area.id })}
+ class="rounded-md bg-action-danger px-3 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-red-600 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-red-500/20"
+ >
+
+
+
-
- {/each}
+ {/each}
+
diff --git a/src/routes/(protected)/admin/restrictions/+page.svelte b/src/routes/(protected)/admin/restrictions/+page.svelte
index 0468c12..bf7ac5a 100644
--- a/src/routes/(protected)/admin/restrictions/+page.svelte
+++ b/src/routes/(protected)/admin/restrictions/+page.svelte
@@ -42,73 +42,89 @@
message="Are you sure you want to delete this restriction? This action cannot be undone."
/>
-
-
+
+
+
Restrictions Management
+
+ Configure airspace restrictions based on Letters of Agreement (LOAs) and Standard Operating
+ Procedures (SOPs). These restrictions help ensure compliance with inter-facility coordination
+ requirements.
+
+
+
+
+
-
-
-
-
-
-
-
-
Airport
-
Route
-
Priority
-
From
-
Restriction
-
To
-
Notes
-
Validity
-
Actions
-
+
+
+
+
+
Airport
+
Route
+
Priority
+
From
+
Restriction
+
To
+
Notes
+
Validity
+
Actions
+
-
-
- {#each filteredRestrictions as restriction (restriction.id)}
-
-
{restriction.airport}
-
{restriction.route}
-
{restriction.priority}
-
{restriction.from}
-
{restriction.restriction}
-
{restriction.to}
-
{restriction.notes}
-
{restriction.validAt || 'N/A'}
-
-
restrictionForm.edit(restriction)}
- >
-
-
-
confirmModal.prompt({ id: restriction.id })}
- class="rounded-md bg-action-danger px-3 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-red-600 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-red-500/20"
- >
-
-
+
+
+ {#each filteredRestrictions as restriction (restriction.id)}
+
+
{restriction.airport}
+
{restriction.route}
+
{restriction.priority}
+
{restriction.from}
+
{restriction.restriction}
+
{restriction.to}
+
{restriction.notes}
+
{restriction.validAt || 'N/A'}
+
+ restrictionForm.edit(restriction)}
+ >
+
+
+ confirmModal.prompt({ id: restriction.id })}
+ class="rounded-md bg-action-danger px-3 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-red-600 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-red-500/20"
+ >
+
+
+
-
- {/each}
+ {/each}
+
diff --git a/src/routes/(protected)/admin/splits/+page.svelte b/src/routes/(protected)/admin/splits/+page.svelte
index 030ffce..6df86d0 100644
--- a/src/routes/(protected)/admin/splits/+page.svelte
+++ b/src/routes/(protected)/admin/splits/+page.svelte
@@ -54,11 +54,46 @@
message="Are you sure you want to delete this split? This action cannot be undone."
/>
-
-
-
Split Management
+
+
+
+
+
Splits Management
+
+ Add and update splits for setting up how the airspace should be delegated between positions.
+
+
+
+
+
+
+
+
+
+ Split Groups represent the individual positions that will own the underlying airspace.
+ Each split configuration defines how the airspace is divided and delegated between
+ different controller positions.
+
+
+
+
+
+
+
-
-
+
{#each filteredSplits as split}
- {split.isPublished ? 'Published' : 'Draft'}
+
+ {split.isPublished ? 'Unpublish' : 'Publish'}
{/if}
diff --git a/src/routes/airspace/+page.server.ts b/src/routes/airspace/+page.server.ts
index 367d838..7cbf4de 100644
--- a/src/routes/airspace/+page.server.ts
+++ b/src/routes/airspace/+page.server.ts
@@ -1,5 +1,7 @@
import { db } from '$lib/server/db';
import {
+ airspaceOverlayComponentsTable,
+ airspaceOverlayGroupsTable,
airspaceStaticElementComponentsTable,
airspaceStaticElementGroupsTable,
splitsTable
@@ -16,12 +18,29 @@ type StaticElementGroup = {
}[];
};
+type OverlayGroups = {
+ id: string;
+ name: string;
+ components: {
+ name: string;
+ color: string;
+ geojson: string;
+ };
+};
+
export async function load({ locals }) {
const splits = await db
.select()
.from(splitsTable)
.where(or(eq(splitsTable.isPublished, true), locals.user && locals.user.isAdmin));
+ const staticElementGroups = await loadStaticElementGroups(locals);
+ const overlayGroups = await loadOverlayGroups(locals);
+
+ return { splits, staticElementGroups, overlayGroups };
+}
+
+async function loadStaticElementGroups(locals: App.Locals): Promise
{
const staticElementsResults = await db
.select()
.from(airspaceStaticElementGroupsTable)
@@ -37,7 +56,7 @@ export async function load({ locals }) {
);
// Group results by static element groups and their components
- const staticElementGroups: StaticElementGroup[] = Object.values(
+ return Object.values(
staticElementsResults.reduce((acc: Record, row) => {
const group = row.airspace_static_element_groups;
@@ -55,6 +74,40 @@ export async function load({ locals }) {
return acc;
}, {})
);
+}
- return { splits, staticElementGroups };
+async function loadOverlayGroups(locals: App.Locals): Promise {
+ const overlayGroupsResult = await db
+ .select()
+ .from(airspaceOverlayGroupsTable)
+ .leftJoin(
+ airspaceOverlayComponentsTable,
+ eq(airspaceOverlayGroupsTable.id, airspaceOverlayComponentsTable.groupId)
+ )
+ .where(
+ or(
+ eq(airspaceOverlayGroupsTable.isPublished, true),
+ locals.user && locals.user?.isAdmin === true
+ )
+ );
+
+ // Group results by static element groups and their components
+ return Object.values(
+ overlayGroupsResult.reduce((acc: Record, row) => {
+ const group = row.airspace_overlay_groups;
+
+ if (!acc[group.id]) {
+ acc[group.id] = {
+ ...group,
+ components: []
+ };
+ }
+
+ if (row.airspace_overlay_components) {
+ acc[group.id].components.push(row.airspace_overlay_components);
+ }
+
+ return acc;
+ }, {})
+ );
}
diff --git a/src/routes/airspace/+page.svelte b/src/routes/airspace/+page.svelte
index ab6bfec..a9397c2 100644
--- a/src/routes/airspace/+page.svelte
+++ b/src/routes/airspace/+page.svelte
@@ -21,7 +21,11 @@
{:else}
-
+
{/if}