feat: polish
This commit is contained in:
parent
2e6177fe74
commit
4bd927bb4e
137 changed files with 6357 additions and 137560 deletions
152
docs/ADDING_A_FEATURE.md
Normal file
152
docs/ADDING_A_FEATURE.md
Normal 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
105
docs/ARCHITECTURE.md
Normal 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 workspace’s 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
132
docs/CONVENTIONS.md
Normal 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 2–3 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). Don’t 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
|
||||
|
||||
- Don’t reach into `maplibre-gl` directly from a feature component — extend
|
||||
`IMap` or add a helper in `$map` instead.
|
||||
- Don’t mutate localStorage from a component. Use a persisted store.
|
||||
- Don’t perform fetches from a component. Call a `$api` module.
|
||||
- Don’t redirect the user on auth failures from individual pages. Use
|
||||
`requireAuthenticated()` at the top of `onMount`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue