feat: polish

This commit is contained in:
Anatoly Antonov 2026-04-22 01:27:38 +09:00
parent 2e6177fe74
commit 4bd927bb4e
137 changed files with 6357 additions and 137560 deletions

152
docs/ADDING_A_FEATURE.md Normal file
View file

@ -0,0 +1,152 @@
# Adding a feature
Walk-through for building a new side-panel feature. We'll use a hypothetical
"NOTAMs" panel (no-fly-zone overlays) as the example.
## 1. Pick a feature folder
```
src/lib/features/notams/
├── index.ts
├── store.ts
├── types.ts
├── NotamsPanel.svelte
└── NotamRenderer.svelte # if you draw on the map
```
Re-export the public surface from `index.ts`.
## 2. Define the domain
If the feature introduces a durable type (something saved to a server or
localStorage), put the type in `src/lib/domain/notam.ts` and re-export from
`src/lib/domain/index.ts`. Ephemeral state types can live in the feature's
`types.ts`.
```ts
// src/lib/domain/notam.ts
export interface Notam {
id: string;
code: string;
polygon: LatLngTuple[];
validFrom: string;
validTo: string;
}
```
## 3. Add state
Feature-scoped stores go in the feature folder. Use `persisted()` if the user
should not lose the state on reload, plain `writable()` otherwise.
```ts
// src/lib/features/notams/store.ts
import { persisted } from '$state';
import type { Notam } from '$domain';
export const notamsStore = persisted<Notam[]>('notams', []);
```
## 4. Add API (if needed)
```ts
// src/lib/api/notams.ts
import { api } from './client';
import type { Notam } from '$domain';
export const notamsApi = { list: () => api.get<Notam[]>('/notams/') };
```
And export from `src/lib/api/index.ts`.
## 5. Build the panel
Re-use UI primitives. Wrap everything in `CollapsibleCard` so it lands in the
side panel.
```svelte
<!-- src/lib/features/notams/NotamsPanel.svelte -->
<script lang="ts">
import { CollapsibleCard } from '$ui';
import { t } from '$i18n';
import { notamsStore } from './store';
</script>
<CollapsibleCard title={$t('notams.title')}>
{#each $notamsStore as notam (notam.id)}
<div>{notam.code}</div>
{/each}
</CollapsibleCard>
```
## 6. Add i18n keys
Append to `src/lib/i18n/locales/ru.json` and `en.json`. The lookup is
dot-separated:
```json
{
"notams": {
"title": "NOTAMs",
"empty": "Нет активных NOTAMs"
}
}
```
## 7. Draw on the map (optional)
Every on-map drawing goes through a Scene. One scene per feature, or per sub-
entity if the feature manages many independent overlays (like workspaces).
```svelte
<!-- src/lib/features/notams/NotamRenderer.svelte -->
<script lang="ts">
import { getMap } from '$map';
import { notamsStore } from './store';
const map = getMap();
if (!map) throw new Error('NotamRenderer requires a <Map /> ancestor');
$effect(() => {
const scene = map.scene('notams');
scene.clear();
for (const n of $notamsStore) {
// scene.addLine/addCircle/etc.
}
});
</script>
```
Add `<NotamRenderer />` as a child of `<MapView />` in the predict route.
## 8. Wire into the route
```svelte
<!-- src/routes/predict/+page.svelte -->
<MapView>
<WorkspaceRenderer />
<NotamRenderer />
<PanelContainer position="right">
<TabBar tabs={[...] } bind:active={rightTab} />
{#if rightTab === 'notams'}
<NotamsPanel />
{/if}
</PanelContainer>
</MapView>
```
## 9. Add a settings entry (optional)
If the feature should be toggleable, extend `src/lib/features/settings/store.ts`
and `schema.ts`. Keep the field declarative (kind + labelKey + path) — the
SettingsPanel renders it automatically.
## 10. Checklist before you open a PR
- [ ] `npm run check` passes
- [ ] `npm run build` passes
- [ ] All user-visible strings routed through `$t`
- [ ] Feature only depends on shared modules (`$domain`, `$state`, `$api`,
`$ui`, `$map`) — not on other features' internals
- [ ] `index.ts` exports only what the outside world needs
- [ ] Panel matches existing visual language (CollapsibleCard, Bootstrap sm
form controls, same spacing)

105
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,105 @@
# Architecture
## Layer overview
```
src/
├── routes/ # SvelteKit pages (thin; they compose features)
├── app.html # plain SPA shell
├── app.css # global styles (imports Bootstrap + bootstrap-icons)
└── lib/
├── domain/ # pure types + pure functions (no Svelte, no fetch)
├── state/ # store factories (persisted, broadcast across tabs)
├── api/ # HTTP client + resource modules (points, scenarios…)
├── auth/ # auth store, login/logout, requireAuthenticated()
├── i18n/ # t() store, locale loading, message dictionaries
├── map/ # IMap interface + MapLibre impl + plot helpers + tools
├── ui/ # library-agnostic primitives (panel, tab, editor…)
└── features/ # vertical slices — one folder per feature
├── auth/ # Navbar, LoginForm
├── footer/
├── prediction/ # ControlPanel, ScenarioPanel, Point/Scenario/Curve editors
├── tracking/ # TelemetryPanel
├── workspaces/ # multi-layer workspace system
├── timeline/ # global playback clock
└── settings/ # schema-driven settings panel
mocks/ # dev-server API mock (enabled via VITE_USE_MOCK_API)
```
**Rules of thumb**
- `domain/` depends on nothing.
- `state/`, `i18n/`, `api/` depend only on `domain/`.
- `map/` depends on `domain/` (and Maplibre, internally).
- `ui/` depends on `domain/`, `state/`, `i18n/` at most.
- `features/*` depend on everything in `lib/` but never on each other directly
— cross-feature coordination happens through stores (e.g. `workspacesStore`
is shared by prediction + timeline).
- `routes/` compose features. Pages stay thin.
## Data flow
### Stores
`$state` → persisted via `persisted()` (localStorage + BroadcastChannel for
cross-tab sync). `workspaces`, `settings`, and `auth` state live here.
### API calls
All go through `$api` (`src/lib/api/client.ts`) which:
- prepends `VITE_API_BASE_URL`
- reads the `csrftoken` cookie (priming it via `/api/csrf/` if missing)
- serializes JSON bodies and parses structured DRF errors into `ApiError`
- delegates 401s to the auth layer (`setUnauthorizedHandler`)
### Auth
`authStore` is the single source of truth for who is logged in. Pages that
require auth call `requireAuthenticated()` from `$auth` in `onMount` — this
refreshes state and redirects to `/login` if anonymous. The API client triggers
the same redirect on 401s.
### Map
The map library is behind the `IMap` interface. Every plot operation (line,
marker, circle) runs through a **Scene** — a named collection of layers owned
by a feature. When a feature is done with its layers, `scene.clear()` or
`map.disposeScene(name)` removes them all at once. This is how each workspace
paints an independent trajectory without stepping on its neighbors.
Swapping MapLibre for another library means implementing `IMap` once (see
`src/lib/map/maplibre.ts`) and updating `createMapLibreMap()` at one
call-site inside `Map.svelte`.
### Workspaces
A workspace is an independently-configured prediction layer. The user creates
several, edits their flight parameters separately, runs each one, and sees all
their trajectories overlaid on the same map with their own colors and
opacities. `workspacesStore.run(id)` calls the prediction API and stores the
result in-memory on that workspace.
`WorkspaceRenderer.svelte` listens to the workspaces store and repaints layers
when workspaces change. It also drives the **global timeline**: whenever
workspaces change it recomputes the global `[min, max]` time range (the union
of every visible workspaces trajectory span) and updates the timeline store.
### Timeline
`timelineStore` is a plain writable backed by a `requestAnimationFrame` loop.
It exposes `play`/`pause`/`seek`/`setSpeed`. `WorkspaceRenderer` subscribes to
`time` and draws a cursor marker on each visible workspace at that timestamp.
Workspaces stay in sync because they all sample the same clock.
### i18n
`initI18n()` (called from the root layout) loads the saved locale from
localStorage, imports the JSON dictionary, and stores it. Components use the
`$t` store from `$i18n`:
```svelte
<h5>{$t('panel.workspaces')}</h5>
```
Adding a locale: drop `xx.json` under `src/lib/i18n/locales/`, register it in
`loaders` and in the `Locale` type.
## Build & deploy
- `@sveltejs/adapter-static` with `fallback: 'index.html'` produces a pure SPA.
- SSR is disabled in the root `+layout.ts` (`ssr = false`, `prerender = false`).
- CSS is imported once in `+layout.svelte` (which loads `app.css`).
- No server-side routes. All data comes from the Django REST backend.

132
docs/CONVENTIONS.md Normal file
View file

@ -0,0 +1,132 @@
# Conventions
## TypeScript
- Every file is `.ts` or `<script lang="ts">`. No plain JS outside build config.
- Types live next to the code they describe. Cross-feature types go in
`src/lib/domain/`.
- Prefer `interface` for object shapes, `type` for unions/aliases.
- Avoid `any`. `unknown` + a narrowing cast is preferred.
## File & folder naming
- **Components**: `PascalCase.svelte`. Exception: pseudo-routes `+page.svelte`
etc. per SvelteKit.
- **Plain modules**: `kebab-or-camel.ts`.
- **Feature directories**: lowercase (`workspaces`, `prediction`).
- Each feature folder has an `index.ts` that re-exports its public surface;
outside code imports from the index, not from individual files.
## Component naming inside `<script>`
- `$state`/`$derived` variables — camelCase, no prefix.
```ts
let isCollapsed = $state(false);
let currentPoint = $derived($pointsStore.find(p => p.id === id));
```
- Component instance refs — camelCase + `Ref`.
```ts
let pointEditorRef: PointEditor | null = $state(null);
```
- Event handlers — `handleXxx`.
```ts
function handleSave() { … }
```
- Props that are event callbacks — `onXxx`.
```svelte
let { onSelect = () => {} }: Props = $props();
```
- HTML `id` attributes — kebab-case, prefixed with a 23 letter component code
(`cp-start-time` for ControlPanel). This keeps IDs globally unique.
- Stores — PascalCase + `Store` (for top-level domain stores) OR camelCase
(for feature-scoped stores). Both are fine; be consistent within a module.
## Props contract
Use the Svelte 5 `Props` interface pattern:
```ts
interface Props {
title?: string;
collapsed?: boolean;
onToggle?: () => void;
children?: Snippet;
}
let {
title = '',
collapsed = $bindable(false),
onToggle = () => {},
children,
}: Props = $props();
```
Do not use `export let` in new components.
## State
- Persisted state (survives reload, syncs across tabs): `persisted(key, initial)`
from `$state`.
- Ephemeral UI state (modal open, which tab is active): plain `$state`.
- Cross-component state that isn't user-facing persistence: create a small
factory module under the feature folder (`store.ts`) — never a bare
`writable()` in a `.svelte` file.
## Imports
Use path aliases (`$api`, `$auth`, `$domain`, `$map`, `$state`, `$ui`, `$i18n`,
`$features`). Example:
```ts
import { api } from '$api';
import { authStore, requireAuthenticated } from '$auth';
import type { Prediction } from '$domain';
import { Map, plotPrediction } from '$map';
import { CollapsibleCard, addToast } from '$ui';
import { t } from '$i18n';
import { workspacesStore } from '$features/workspaces';
```
Avoid importing from `src/lib/components/...` (it no longer exists).
## Strings
No hardcoded Russian/English text in new code. Add the key to both
`src/lib/i18n/locales/ru.json` and `en.json` and read it with `$t('...')`.
Placeholders use `{name}`:
```ts
$t('workspaces.deleteConfirm', { name: w.name })
```
## Comments
Default is no comment. Add one only when the *why* is non-obvious (hidden
invariants, workarounds, API quirks). Dont restate what the code does.
Module-level docstrings are welcome: put a short paragraph at the top of a
file that describes the role of the module, mentioning any non-obvious design
choices. See `src/lib/state/persisted.ts` for an example.
## CSS
- Global chrome lives in `src/app.css`.
- Component-scoped styles go inside the `.svelte` file.
- Use Bootstrap utility classes when possible; only add custom CSS when the
design requires it.
- Never use `!important` except to override third-party libs (MapLibre,
Bootstrap).
## Testing
Not set up yet. When tests arrive: feature modules should be testable without
Svelte — that's why `domain/` and `state/` are pure.
## Don't
- Dont reach into `maplibre-gl` directly from a feature component — extend
`IMap` or add a helper in `$map` instead.
- Dont mutate localStorage from a component. Use a persisted store.
- Dont perform fetches from a component. Call a `$api` module.
- Dont redirect the user on auth failures from individual pages. Use
`requireAuthenticated()` at the top of `onMount`.