Skip to content

Frontend Architecture

Entry Point

The application bootstraps in frontend/src/main.ts:

  1. Creates the Vue app instance
  2. Installs plugins: Vue Router, Pinia, TanStack Vue Query, PrimeVue (Aura preset, light mode forced)
  3. Registers GisIcon (from @uikit/icon/icon.vue) as the only global component
  4. Imports global styles: remixicon.css, mapbox-gl.css, and main.css (Tailwind + UIKit + theme)
  5. 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)

PathPageDescription
/modules/accidents/pages/accidents.vueInteractive map with accident data
/breakingmodules/breaking/pages/breaking.vueHard-braking events map
/speedmodules/speed/pages/speed.vueMeasured-speed map (NIRA segments)
/accidents/reportmodules/accidents/pages/accident-report.vueFull-page printable report for a drawn polygon
/breaking/reportmodules/breaking/pages/breaking-report.vueSame, for braking events
/speed/reportmodules/speed/pages/speed-report.vueSame, for measured speeds
/profilemodules/profile/pages/profile.vueCurrent-user profile editor

Auth Layout (centered, no sidebar) — meta.public: true

PathPage
/loginLogin form
/forgot-passwordPassword reset request
/reset-passwordToken-based password reset
PathPage
/privacyPrivacy policy
/cookiesCookie policy
/termsTerms 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 (SidebarMenuItem components)
  • 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.

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 in shared/styles/map-page.scss (global, imported by each map page).
  • .gis-map-page__topbar — 64px white band at the top with border-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 default top-right so it doesn't collide with the actions cluster).
  • Top-right actions cluster — shared shared/components/map-actions.vue renders Ustvari poročilo + Izvozi + Kritične lokacije as floating pill buttons. Critical locations dropdown opens inline under its button via CriticalLocationsPanel floating={false}.
  • Report pagesshared/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 handoffshared/utils/reportPolygonStorage.ts writes the drawn polygon to sessionStorage keyed 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 cards
  • filters/ — Filter bar with multiselect dropdowns, road section selector, date range picker, custom date modal
  • cluster/ — Popover for overlapping accident markers

Composables (10):

  • useAccidentMapQuery — Tile-based map data fetching with zoom-aware segmentation
  • useAccidentDetailQuery — Single accident detail query
  • useAccidentFiltersQuery — Filter options (cached with staleTime: Infinity)
  • useAccidentsQuery — List query for accidents
  • useAccidentMarkers — Advanced marker management and clustering
  • useMapViewport — Map bounds and zoom state management
  • useRoadPolylines — Road section density visualization with color scales
  • useRoadSectionsQuery — Road section geometry fetching
  • useMapLegend — Legend overlay creation and positioning

API layer (api/accidents.api.ts):

  • fetchAccidents(filters?) — Get accident markers
  • fetchAccidentMapData(zoom, bounds, filters) — Tile-based data
  • fetchAccidentDetail(id) — Full accident details
  • fetchAccidentFilters() — Filter dropdown options
  • fetchRoadSections(bounds?) — Road section boundaries
  • fetchRoadSectionGeometries() — Road geometries
  • exportAccidentsCsv(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' step expression on speedPct85 / speedAvg / speedPct15 emitted by the MVT.
  • speed-map-legend.vue — title updates to match the active mode.
  • Backend pairingtiles.speed_lines(z,x,y, …, min_pct15, max_pct15) (migration 1700000000025) and SpeedService.findCriticalLocations apply 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.

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.

ComposablePurpose
useMapTilingSplits 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.
useMapboxInstanceBoots 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).
usePaginationBounds-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 via VITE_MAPBOX_STYLE
  • Markers: mapboxgl.Marker with custom HTML content
  • Clustering: Client-side Haversine distance clustering (10m radius)
  • Density: GeoJSON line layers with per-feature color paint
  • Legend: Custom IControl positioned 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, unfilteredpg_tileserv directly: /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, default http://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 the breaking equivalents. The proxy validates filters with class-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.