Appearance
Frontend Architecture
Entry Point
The application bootstraps in frontend/src/main.ts:
- Creates the Vue app instance
- Installs plugins: Vue Router, Pinia, TanStack Vue Query, PrimeVue (Aura preset, light mode forced)
- Registers
GisIcon(from@uikit/icon/icon.vue) as the only global component - Imports global styles:
remixicon.css,mapbox-gl.css, andmain.css(Tailwind + UIKit + theme) - Mounts to
#app
The TanStack Query plugin options live in src/shared/api/queryClient.ts.
Routing
Routes are defined in frontend/src/router/index.ts with three layout groups. A router.beforeEach guard hydrates the auth store, redirects unauthenticated users on non-public routes to /login, and bounces authenticated users away from /login.
Default Layout (sidebar + content)
| Path | Page | Description |
|---|---|---|
/ | modules/accidents/pages/accidents.vue | Interactive map with accident data |
/breaking | modules/breaking/pages/breaking.vue | Hard-braking events map |
/speed | modules/speed/pages/speed.vue | Measured-speed map (NIRA segments) |
/accidents/report | modules/accidents/pages/accident-report.vue | Full-page printable report for a drawn polygon |
/breaking/report | modules/breaking/pages/breaking-report.vue | Same, for braking events |
/speed/report | modules/speed/pages/speed-report.vue | Same, for measured speeds |
/profile | modules/profile/pages/profile.vue | Current-user profile editor |
Auth Layout (centered, no sidebar) — meta.public: true
| Path | Page |
|---|---|
/login | Login form |
/forgot-password | Password reset request |
/reset-password | Token-based password reset |
Legal Layout (header + footer) — meta.public: true
| Path | Page |
|---|---|
/privacy | Privacy policy |
/cookies | Cookie policy |
/terms | Terms of use |
All page components are lazy-loaded using dynamic imports:
typescript
component: () => import('@/modules/accidents/pages/accidents.vue')Layouts
Default Layout (shared/layouts/default.vue)
The main application layout with a responsive sidebar:
- Desktop: Fixed sidebar (270px) + scrollable content area
- Mobile (<1024px): Sidebar transforms to a slide-out drawer with backdrop overlay
- Mobile header: 3.5rem height with hamburger menu toggle
The sidebar contains:
- Logo and branding
- Navigation links (
SidebarMenuItemcomponents) - User profile card
- Legal links and cookie settings
Auth Layout (shared/layouts/auth.vue)
Minimal centered layout for authentication pages. Full-height flexbox with neutral background.
Legal Layout (shared/layouts/legal.vue)
Max-width (3xl) content area with header (logo) and footer (legal links).
Map page shell
All three map modules (Nesreče / Zaviranja / Hitrosti) render inside the Default Layout but share their own internal chrome via utility classes and shared components:
.gis-map-page— flex-column container the page sits in. Defined inshared/styles/map-page.scss(global, imported by each map page)..gis-map-page__topbar— 64px white band at the top withborder-left+border-bottom(no card/radius/shadow). Holds the module's filter bar — filters overflow-x-scroll rather than wrap.- Map area — fills the remaining height; Mapbox NavigationControl is pinned to
bottom-right(moved from the defaulttop-rightso it doesn't collide with the actions cluster). - Top-right actions cluster — shared
shared/components/map-actions.vuerendersUstvari poročilo+Izvozi+Kritične lokacijeas floating pill buttons. Critical locations dropdown opens inline under its button viaCriticalLocationsPanel floating={false}. - Report pages —
shared/components/report-page-chrome.vue(back + Prenesi PDF bar) +shared/styles/report-page.scss(shared.gis-report__*styles including@media print). Used by all three report pages. - Polygon handoff —
shared/utils/reportPolygonStorage.tswrites the drawn polygon tosessionStoragekeyed by module (gis.pendingReportPolygon,.breaking,.speed); the report page reads it on mount and clears it on back.
Modules
Feature code is organized in src/modules/<name>/:
Accidents Module (modules/accidents/)
The primary feature module — an interactive map for exploring traffic accident data.
Page (pages/accidents.vue):
- Full-page Mapbox map view
- Filter bar (top of map)
- Accident detail drawer (right side)
- Cluster popover
- Map legend
Components:
detail/— Tabbed detail view (Accident, Conditions, Location tabs) with participant cardsfilters/— Filter bar with multiselect dropdowns, road section selector, date range picker, custom date modalcluster/— Popover for overlapping accident markers
Composables (10):
useAccidentMapQuery— Tile-based map data fetching with zoom-aware segmentationuseAccidentDetailQuery— Single accident detail queryuseAccidentFiltersQuery— Filter options (cached withstaleTime: Infinity)useAccidentsQuery— List query for accidentsuseAccidentMarkers— Advanced marker management and clusteringuseMapViewport— Map bounds and zoom state managementuseRoadPolylines— Road section density visualization with color scalesuseRoadSectionsQuery— Road section geometry fetchinguseMapLegend— Legend overlay creation and positioning
API layer (api/accidents.api.ts):
fetchAccidents(filters?)— Get accident markersfetchAccidentMapData(zoom, bounds, filters)— Tile-based datafetchAccidentDetail(id)— Full accident detailsfetchAccidentFilters()— Filter dropdown optionsfetchRoadSections(bounds?)— Road section boundariesfetchRoadSectionGeometries()— Road geometriesexportAccidentsCsv(filters)— CSV export
Utilities:
formatters.ts— Date, time, driving experience formatting (Slovenian locale)clusterAccidents.ts— Haversine distance-based clustering (10m radius)
Breaking Module (modules/breaking/)
Map view for hard-braking telemetry events. Same component/composable shape as accidents/ but driven by the /api/breaking endpoints.
Speed Module (modules/speed/)
Map view for measured speeds across NIRA segments. Three sub-layers (pct85 / avg / pct15) drive segment colouring, chosen via a segmented control in the filter bar. A single "speed range" input follows the active sub-layer: typing min/max emits minPct85/maxPct85, minAverage/maxAverage, or minPct15/maxPct15 depending on the selected mode, and re-emits on mode change so the range re-applies against the new column.
useSpeedLines— builds the vector source; the display mode drives the'line-color'stepexpression onspeedPct85/speedAvg/speedPct15emitted by the MVT.speed-map-legend.vue— title updates to match the active mode.- Backend pairing —
tiles.speed_lines(z,x,y, …, min_pct15, max_pct15)(migration1700000000025) andSpeedService.findCriticalLocationsapply the same three bound pairs.
Auth Module (modules/auth/)
Login and password-management pages (login.vue, forgot-password.vue, reset-password.vue) using Vuelidate for form validation. Calls POST /api/auth/login which sets the access_token httpOnly cookie.
Profile Module (modules/profile/)
Self-service profile editor — name update and password change against /api/auth/profile and /api/auth/change-password.
Legal Module (modules/legal/)
Static content pages (privacy policy, cookie policy, terms of use) in Slovenian. Each page (pages/{privacy,cookies,terms}.vue) is a thin shell that hands a content object to components/legal-page-template.vue. The actual copy lives in content/{privacy,cookies,terms}.content.ts, typed against content/legal.types.ts. To edit a legal page, change the .content.ts file — the template handles headings, intro HTML, internal data-internal anchor interception, and shared markup styles.
Shared Composables (src/shared/composables/)
Cross-module Vue composables that the accidents, breaking, and speed modules consume — extracted from previously duplicated logic. New modules that need any of these patterns should reuse the shared composable rather than rolling their own.
useCriticalLocations(moduleKey, filters?) returns the top-N critical segments for a module. Pass a reactive Ref<filters> to make the list follow the map filters — it's serialised into the /critical-locations URL and included in the TanStack query key so results refetch on change.
| Composable | Purpose |
|---|---|
useMapTiling | Splits a viewport into degree-aligned tiles per density-segment level (TILE_DEGREES_BY_SEGMENT) so map fetches can be cached per tile rather than per pan. |
useMapboxInstance | Boots a Mapbox GL map into a container ref with the Slovenia-centred defaults (SLOVENIA_CENTER, SLOVENIA_BOUNDS), runs an onReady hook, and tears it down on unmount. |
useDetailDrawer | "Select an item → open a side drawer" state holder; keeps selectedId and drawerOpen in sync and fires an optional onClose hook (e.g. clear marker selection). |
usePagination | Bounds-safe pagination derived state — totalPages is always ≥ 1 and currentPage snaps back to 1 if it ever exceeds the page count. |
Each composable has a colocated .spec.ts covering the edge cases the original duplicates kept getting wrong (zero-result pagination, drawer close-on-id-clear, tile alignment at segment-size boundaries, Mapbox cleanup).
State Management
Pinia Stores (shared/stores/)
Two top-level stores:
app.ts — UI/application state:
typescript
const useAppStore = defineStore('app', () => {
const isMobile = ref(false); // Tracks lg breakpoint (1024px)
const sidebarOpen = ref(true); // Sidebar visibility
function toggleSidebar() { ... }
function closeSidebar() { ... }
return { isMobile, sidebarOpen, toggleSidebar, closeSidebar };
});Uses MediaQueryList listener to detect breakpoint changes. Auto-closes sidebar on mobile when route changes.
auth.ts — Current-user session state. The router calls authStore.fetchMe() on first navigation to populate the user from GET /api/auth/me (which reads the JWT cookie). Exposes isAuthenticated, isLoading, user, login, logout.
TanStack Query
Server state is managed via TanStack Vue Query, not Pinia. Composables wrap query hooks to provide:
- Automatic caching and deduplication
- Background refetching
- Loading and error states
Mapbox Integration
The map uses Mapbox GL JS:
- Access token: Set via
VITE_MAPBOX_ACCESS_TOKEN - Style: Defaults to
mapbox://styles/mapbox/streets-v12, overridable viaVITE_MAPBOX_STYLE - Markers:
mapboxgl.Markerwith custom HTML content - Clustering: Client-side Haversine distance clustering (10m radius)
- Density: GeoJSON line layers with per-feature color paint
- Legend: Custom
IControlpositioned in the bottom-left corner
Zoom-Based Data Loading
The map uses different data strategies based on zoom level:
- Zoom < 16: Density heatmap (aggregated by road segment, sizes 250m–4000m based on zoom)
- Zoom ≥ 16: Individual accident markers with clustering
Vector Tiles (MVT)
Heavy geometry layers (road sections, density per segment, per-event markers for accidents and braking) are loaded as Mapbox Vector Tiles, not JSON. Tile URLs flip between two backends at runtime:
- Public, unfiltered —
pg_tileservdirectly:/tiles/tiles.accidents_density/{z}/{x}/{y}.pbf,/tiles/tiles.road_sections/{z}/{x}/{y}.pbf,/tiles/tiles.breaking_density/{z}/{x}/{y}.pbf. Proxied by Vite (TILESERV_URL, defaulthttp://localhost:7800) and by the production frontend nginx. - Filtered or company-scoped — NestJS proxy:
/api/tiles/accidents/{z}/{x}/{y}.pbf?...,/api/tiles/accidents/points/{z}/{x}/{y}.pbf?..., and thebreakingequivalents. The proxy validates filters withclass-validator, resolves the user's company scope server-side, and calls the SQL function as the application user.
See Tile Serving for the full architecture (PostGIS tiles schema, tileserv role, function-vs-proxy split).
Vite Configuration
typescript
// frontend/vite.config.ts
export default defineConfig({
envDir: fileURLToPath(new URL('..', import.meta.url)), // load /.env at repo root
server: {
port: 8000,
proxy: {
'/api': {
target: process.env.API_URL || 'http://localhost:8001',
changeOrigin: true,
},
'/tiles': {
target: process.env.TILESERV_URL || 'http://localhost:7800',
changeOrigin: true,
},
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@uikit': fileURLToPath(new URL('../shared/src/uikit', import.meta.url)),
'@theme': fileURLToPath(new URL('../shared/src/assets/styles', import.meta.url)),
},
},
});The proxies mean all /api/* and /tiles/* requests from the browser are forwarded same-origin — to the NestJS backend and pg_tileserv respectively — avoiding CORS issues and keeping the JWT cookie attached. envDir: '..' causes Vite to load the root .env, so only VITE_* vars (e.g. VITE_MAPBOX_ACCESS_TOKEN) are exposed to the browser, while API_URL and TILESERV_URL are consumed by the dev-server proxy at build/start time.