# Map

Level: organism. Inventory status: `local-code`.

## Purpose

Provide a reusable desktop geographic map that resizes with its container and
supports pan, zoom, rotation, light/dark cartographic styles, markers and
polygon editing. It owns client-side map interaction only; feature data loading,
permissions, persistence and navigation remain outside the design system.

## Confirmed correction target

The resumed M-003 correction implements and verifies these confirmed
requirements:

- 20 px control insets;
- real light and dark cartographic Storybook underlays supplied only by a demo
  provider, while the runtime remains provider-neutral;
- guaranteed visibility of all supplied geometry after every successful
  `style.load`;
- one surfaced Draw action bar made from Flat Primary actions, with Delete-all
  adjacent to the Delete tool;
- polygon, zoom and auxiliary Tooltip placement at right, left and top
  respectively;
- a cursor-following draw hint until the first accepted polygon point;
- an edit-selection hint, persistent edit mode, pointer cursor on vertices and
  a dirty-state Save action beside Edit;
- staged delete selection with a danger highlight, explicit Delete/Delete-all
  actions and owned Modal confirmation before geometry changes;
- one-level undo for the last confirmed single or Delete-all operation;
- fixed polygon-tool rows that do not move when contextual actions appear;
- light Tooltip surfaces with dark text for every control Tooltip and
  cursor-following hint in the dark map theme;
- polygon-to-polygon snapping during creation and vertex editing, using a
  16 px pointer threshold for both vertices and line segments;
- resize recovery that coalesces host observations, skips zero-size frames and
  repaints the existing MapLibre map without replacing its style;
- circular Number/Icon pins and separate Pointer anatomy defined by Map Pin.

## Runtime boundary

M-003 uses MapLibre GL JS 6.8.0 for rendering and interaction, Terra Draw 1.32.3
with its MapLibre adapter for polygon creation/editing, and caller-provided
light/dark style definitions. Figma raster backgrounds are visual evidence only
and must never be shipped as the interactive map.

Runtime code stays provider-neutral: it accepts MapLibre styles and contains no
owned tile-provider endpoint or credential. Storybook must demonstrate both
themes with real cartographic light/dark styles from an explicitly demo-only
provider; a flat color layer is not an acceptable dark-map story.

The Angular build publishes MapLibre's worker and shared ESM modules under the
application base URL. The component resolves the worker from `document.baseURI`
before creating a map, so vector tiles and owned GeoJSON layers work in both the
application and Storybook builds.

## Anatomy and layout

- A MapLibre canvas fills the host's current inline and block size.
- `main` has no component-owned border or radius.
- `mini` uses a secondary 1 px stroke and the large clipping radius.
- Map Zoom is vertically centered at the right edge with a 20 px inset.
- Info, Settings and fullscreen actions form an 8 px-gap group with 20 px
  bottom/right insets.
- Polygon actions form a vertical group with 20 px top/left insets.
- Draw mode exposes one action-bar surface to the right of the Draw tool. The
  surface uses the level-1-base background, secondary 1 px stroke, 8 px radius,
  dropdown shadow, 12 px inline/4 px block padding and 12 px gap. Its Finish,
  Undo-last-point and Cancel actions reuse Flat Primary at 24 px height.
- The Delete row uses the same surfaced action treatment. It exposes separate
  Flat Danger actions for the selected polygon (`Удалить`) and all polygons
  (`Удалить все`), plus `Отменить удаление` while a one-level undo is available.
  Delete actions open an owned Modal confirmation instead of mutating geometry
  directly.
- Single-delete and Delete-all confirmations are distinct owned Modal dialogs.
  Their destructive actions repeat the initiating label; `Отмена` is the safe
  initial focus target.
- Each Draw/Edit/Delete tool owns a fixed row. Contextual action surfaces are
  rendered beside their owning tool and never change the vertical positions of
  another polygon row. Opening Draw actions therefore must not push Edit or
  Delete down.
- Pins render above the cartographic style but below the controls; provider
  attribution stays available inside the MapLibre canvas.

The broad Figma sentence that “all controls” are bottom-right is interpreted as
the auxiliary Info/Settings/fullscreen group: exact component layout and the
same specification place zoom right-center and polygon actions top-left.

## Variants and controls

- `main`: Zoom and polygon controls are available by default. Info and Settings
  appear only when their matching template slot is provided.
- `mini`: Zoom and Enter fullscreen are available by default; polygon controls
  are omitted. Info and Settings still require their matching slots.
- `controls` may hide a group without changing its location or visual owner.
- Enter/Exit fullscreen uses the browser Fullscreen API on the mini-map host and
  emits `fullscreenChange`; it does not invent a route or modal.
- Info and Settings toggle anchored dialog overlays whose contents are supplied
  through feature-owned template slots. Neither panel body is defined by the
  provided Figma nodes.

## Map interaction

- Pointer drag and touch pan move the viewport.
- Wheel/trackpad, pinch and Map Zoom change zoom within configured bounds.
- Mouse modifier/right-button drag, touch rotation and MapLibre keyboard
  controls rotate the map. Bearing is part of the controlled viewport.
- A `ResizeObserver` watches the Map host because container resizing may happen
  without a window resize. Notifications are coalesced into one pending
  `requestAnimationFrame`. A frame with zero inline or block size is skipped;
  the observer remains active so the next non-zero frame can recover the map.
  For a valid size, call MapLibre `resize()`, then `triggerRepaint()`, then
  update pin positions. Destroy cancels any queued animation frame. Resize does
  not call `setStyle()`, recreate MapLibre or recreate Terra Draw.
- Theme changes replace the base style while preserving viewport, markers,
  polygon data and enabled interaction state. Owned sources/layers are restored
  after the new style loads.
- The rendered cartographic theme also selects Map-owned Tooltip contrast.
  Light maps use the default dark Tooltip surface with light text. Dark maps
  invert all control overlays and cursor-following hints to a light surface
  with dark text.
- After every successful `style.load`, every supplied Polygon, MultiPolygon,
  LineString and MultiLineString is visible in view mode. Terra Draw may own the
  currently editable geometry, but must not be the only visibility path for
  ordinary caller-supplied single geometries.
- Initialization/style/fullscreen failures emit `loadError`; the component remains
  labelled and does not expose credentials or provider responses.

## Polygon behavior

- `view` allows map navigation without editing geometry.
- `draw-polygon` starts Terra Draw polygon creation and exposes finish, undo-last
  and cancel actions.
- During `draw-polygon`, every new coordinate may snap to the nearest vertex or
  line segment of another Polygon feature when the pointer is within 16 screen
  pixels. LineString features and the Polygon currently being drawn are not
  snapping targets.
- Before the first accepted vertex, Draw mode shows a non-interactive hint next
  to the cursor: “Нажмите, чтобы начать рисовать полигон”. It follows pointer
  movement over the canvas and closes on the first accepted vertex, pointer
  leave, cancel, finish or any mode change. It does not enter the focus order or
  intercept map input.
- `edit-polygon` allows selection and vertex editing of an existing polygon.
- Until a polygon is selected in `edit-polygon`, a non-interactive
  cursor-following hint reads: “Нажмите на полигон, который хотите
  отредактировать”. The hint closes after polygon selection, on pointer leave or
  when the mode ends.
- Selecting a polygon or changing its geometry must not automatically return to
  `view`; edit mode stays active until explicit completion.
- Hovering an editable vertex uses the pointer cursor so the hit target is
  discoverable. Other map areas retain the appropriate map/edit cursor.
- Dragging an individual vertex in `edit-polygon` may snap it to the nearest
  vertex or line segment of another Polygon feature within the same 16 px
  pointer threshold. Lines, the selected Polygon itself and whole-feature drag
  are excluded from snapping.
- Snapping copies the accepted coordinate only. Polygons remain separate GeoJSON
  features: the component does not merge them, create a shared-boundary model or
  keep previously joined coordinates synchronized after a later edit.
- After the first geometry change in the current edit session, a “Сохранить”
  Flat Primary action appears directly beside the Edit tool without moving any
  polygon row. Activating Save explicitly completes the editing session,
  clears its dirty state and returns to `view`. Save describes UI completion,
  not backend persistence; `geometry` remains the controlled data boundary.
- `delete-polygon` is a staged destructive workflow. Clicking a Polygon or
  MultiPolygon selects it through `selectedGeometryId` and applies a semantic
  danger fill/outline; it does not change `geometry`. Clicking another polygon
  moves the pending selection and highlight to that feature.
- A selected polygon exposes a separate `Удалить` action. Activating it opens
  the owned single-delete Modal; only its confirm action removes the selected
  feature. `Удалить все` opens a distinct owned Modal and only its confirm
  action removes every Polygon and MultiPolygon while preserving LineString and
  MultiLineString features. Cancelling either Modal leaves `geometry`
  unchanged.
- `delete-polygon` remains active after a confirmed single-delete, confirmed
  Delete-all or undo. Confirmed deletion clears the pending selection and
  returns the cursor hint to its unselected delete-mode behavior.
- The last confirmed single-delete or Delete-all operation creates one undo
  record containing the removed features and their original collection
  positions. `Отменить удаление` remains available after the operation and
  restores exact IDs, properties, geometry and feature order. A later confirmed
  deletion replaces the previous undo record; this is not a multi-step history.
- Any external `geometry` update or non-delete geometry mutation invalidates
  both the undo record and a pending delete selection. Stale features must not
  be reintroduced over caller data, and a removed/replaced selected ID must not
  remain highlighted or confirmable.
- While `delete-polygon` is active over the canvas, a non-interactive
  cursor-following hint reads: “Нажмите на полигон, который хотите удалить”. It
  closes after a polygon is selected, on pointer leave or when the mode ends.
- Geometry is controlled as a GeoJSON FeatureCollection of Polygon,
  MultiPolygon, LineString and MultiLineString features and emits after changes.
  Backend persistence is out of scope.
- Clicking a polygon vertex in edit mode selects it for keyboard editing.
  Arrow keys move the selected vertex by 1 screen pixel; Shift+Arrow moves it
  by 10 screen pixels. The component announces vertex selection and movement.
  Consumers with precision-coordinate workflows should still provide an
  equivalent coordinate/list editor alongside the spatial canvas.

## Markers

Pins are caller-provided records with stable ID, coordinates, accessible label
and typed Map Pin content. Activating one updates `selectedPinId`. The component
does not fetch marker content or open a product-specific details card.

## Accessibility

- The MapLibre interaction surface is a labelled application region.
  `ariaLabel` should identify its purpose in the current screen, not merely say
  “карта”.
- Keep the MapLibre keyboard surface focusable and visibly focused. Keyboard
  pan, zoom and rotation must remain enabled.
- Every visible control is a labelled native button with Tooltip support on
  hover and focus; visual glyphs remain decorative.
- Polygon-tool Tooltips prefer `right`, Map Zoom Tooltips prefer `left`, and
  bottom-right auxiliary Tooltips prefer `top`; existing Tooltip fallbacks may
  move them only when the preferred side cannot fit the viewport.
- Dark-map control Tooltips and non-interactive cursor hints use the inverted
  Tooltip appearance so their surfaces remain distinct from the cartography.
- Danger color is not the only delete-selection cue: the selected polygon also
  has a distinct outline, and an `aria-live` announcement reports selection,
  confirmed deletion, Delete-all and successful undo.
- Single-delete and Delete-all use the owned Modal accessibility contract. A
  safe non-destructive close control receives initial focus, Escape is
  equivalent to cancel, and cancel returns focus to the action that opened the
  Modal. After confirmation, focus
  moves to the available `Отменить удаление` action so undo remains keyboard
  reachable even when the initiating action disappears.
- Delete and undo controls are labelled native buttons. Opening or cancelling a
  confirmation does not silently change `geometry` or delete-mode state.
- Provider attribution and required license links must remain available.
- Interactive marker labels must expose location meaning. Dense visual pins
  need an equivalent accessible list when spatial targeting is insufficient.
- Motion must respect `prefers-reduced-motion`; camera transitions become
  immediate while direct manipulation remains available.

## Token and theme use

- Mini frame: `--color-stroke-secondary`, `--stroke-100`, `--radius-l`.
- Control spacing: `--space-200` for auxiliary gaps and `--space-500` for the
  confirmed 20 px edge inset. The Draw action bar uses `--space-300` and
  `--space-100` for its verified internal spacing.
- Control visuals and pins use their owning component contracts.
- `theme` selects the cartographic light/dark style; it does not introduce
  component-local raw replacements for the global semantic UI tokens.
- In the light theme, control Tooltips and cursor hints use
  `--color-background-tooltip` with
  `--color-text-icon-primary-inverted`. In the dark theme they use
  `--color-background-level-1-base` with `--color-text-icon-primary`.
- Pending delete selection uses the existing semantic danger color for its
  fill/outline treatment; do not add a raw red or a Map-only danger token. The
  confirmation surface and actions use the owned Modal and Button contracts.
- The six Figma background examples (`world`, `russia`, `moscow-region`,
  `moscow-large`, `moscow-medium`, `moscow-details`) are continuous-zoom visual
  references, not public runtime enum values.

## Storybook coverage target

The main story must show a real cartographic underlay, at least one visible
polygon, one visible line and circular Number/Icon pins. Pointer has a separate
Map Pin story because it is a distinct marker anatomy. Light and Dark stories
must both retain streets, labels and geographic context. Dark demonstrates the
inverted control Tooltip and cursor hint. Polygon Editing Snapping demonstrates two
separate polygons and the 16 px vertex/segment snapping behavior. The story may
name its provider as demo-only; that provider must not become a runtime default.

Polygon Deletion Safety demonstrates Polygon and MultiPolygon selection,
danger highlighting, separate single/Delete-all confirmations, cancellation,
one-level undo with exact restoration and persistent delete mode. Resize
Recovery exercises rapid host resizing and zero-size-to-visible recovery in
both themes; streets, labels, polygons and pins remain visible without a style
reload.

## Focused regression target

- The Draw/Edit/Delete row positions are identical before and after contextual
  actions appear.
- Delete-all is adjacent to Delete and uses Flat Danger.
- Edit hint remains visible until polygon selection; selecting and changing a
  polygon keep `mode === 'edit-polygon'`.
- Save is absent before a geometry change, appears beside Edit after the first
  change and explicitly completes the editing session.
- Editable vertex hover uses the pointer cursor.
- Delete mode exposes the confirmed delete-selection hint.
- Clicking a polygon in delete mode changes only pending selection and danger
  highlight; `geometry` is unchanged until the separate action and Modal confirm.
- Single-delete and Delete-all have distinct confirmations. Cancel and Escape
  preserve geometry, while confirm removes only the intended polygon scope,
  preserves line features and keeps `mode === 'delete-polygon'`.
- Undo after single-delete and Delete-all restores exact feature IDs,
  properties, geometry and collection order. Only the latest confirmed delete
  is undoable, and undo remains keyboard reachable after deletion.
- An external or non-delete geometry mutation clears pending delete selection
  and invalidates undo without restoring stale features.
- Modal focus starts on a safe non-destructive close control, returns to the
  initiating action on cancel and moves to `Отменить удаление` after
  confirmation. Live announcements cover
  selection, deletion and undo; danger color is not the sole selection cue.
- Multiple ResizeObserver notifications before paint schedule one resize frame.
  Zero-size frames do not call MapLibre; the next valid frame calls `resize()`,
  `triggerRepaint()` and pin update in that order, without `setStyle()` or draw
  reinitialization. Destroy cancels the queued frame.
- Every control Tooltip and cursor hint uses a light surface with dark text when
  the rendered map theme is `dark`, and retains the default contrast when it is
  `light`.
- Polygon creation snaps only to vertices and line segments of another Polygon
  within 16 px; line geometry and the in-progress Polygon are excluded.
- Dragging a selected Polygon vertex uses the same snapping targets and
  threshold, while the two Polygon feature IDs and independent geometries are
  preserved.
- Moving a joined coordinate later does not mutate the neighboring Polygon;
  snapping is not a persistent topological relationship.

## Local component contract

This is the M-003 owned contract for `component.map`.

### Public types

```ts
import type {
  Feature,
  FeatureCollection,
  LineString,
  MultiLineString,
  MultiPolygon,
  Polygon,
} from 'geojson';
import type { StyleSpecification } from 'maplibre-gl';

export type WblMapVariant = 'main' | 'mini';
export type WblMapTheme = 'light' | 'dark';
export type WblMapMode = 'view' | 'draw-polygon' | 'edit-polygon' | 'delete-polygon';
export type WblMapLineStyle = 'solid' | 'dashed';
export type WblMapStyle = string | StyleSpecification;

export interface WblMapStyles {
  readonly light: WblMapStyle;
  readonly dark: WblMapStyle;
}

export interface WblMapViewport {
  readonly center: readonly [longitude: number, latitude: number];
  readonly zoom: number;
  readonly bearing: number;
}

export interface WblMapControls {
  readonly zoom?: boolean;
  readonly info?: boolean;
  readonly settings?: boolean;
  readonly fullscreen?: boolean;
  readonly polygons?: boolean;
}

export interface WblMapFeatureProperties {
  readonly strokeColor?: `#${string}`;
  readonly fillColor?: `#${string}`;
  readonly fillOpacity?: number;
  readonly lineStyle?: WblMapLineStyle;
  readonly [key: string]: unknown;
}

export type WblMapGeometry = Polygon | MultiPolygon | LineString | MultiLineString;

export type WblMapFeature = Feature<WblMapGeometry, WblMapFeatureProperties> & {
  readonly id: string | number;
};

export type WblMapFeatureCollection = FeatureCollection<WblMapGeometry, WblMapFeatureProperties> & {
  readonly features: WblMapFeature[];
};

export interface WblMapLoadError {
  readonly code: 'missing-style' | 'map-load' | 'fullscreen';
  readonly message: string;
  readonly cause?: unknown;
}

export interface WblMapOptions {
  readonly styles: WblMapStyles | null;
}
```

`WblMapPinConfig` and its discriminated content types are owned by Map Pin.
Feature properties are pass-through presentation metadata; no backend fields
are required by the design-system type.

`wblMapOptionsProvider(options)` may provide application-level default styles
through `WBL_MAP_OPTIONS`; the component-level `styles` input takes precedence.

### Class and selector

- `WblMapComponent` — `wbl-map`.

### Inputs

- `variant = input<WblMapVariant>('main')`
- `theme = input<WblMapTheme>('light')`
- `styles = input<WblMapStyles | null>(null)`; `null` resolves through
  `WBL_MAP_OPTIONS`
- `controls = input<WblMapControls>(DEFAULT_CONTROLS)`
- `pins = input<readonly WblMapPinConfig[]>([])`
- `ariaLabel = input('Географическая карта')`

Tooltip appearance and the 16 px polygon-snapping threshold are owned runtime
rules derived from `theme` and the active polygon mode; they do not add Map
inputs.

### Models

- `viewport = model<WblMapViewport>(DEFAULT_VIEWPORT)`
- `geometry = model<WblMapFeatureCollection>(WBL_EMPTY_MAP_GEOMETRY)`
- `mode = model<WblMapMode>('view')`
- `selectedPinId = model<string | null>(null)`
- `selectedGeometryId = model<string | null>(null)`

`selectedGeometryId` owns the active Polygon/MultiPolygon selection in both
edit and delete modes. In delete mode selection is pending only and cannot
mutate `geometry` until the matching owned Modal is confirmed.

### Outputs

- `mapReady = output<void>()`
- `loadError = output<WblMapLoadError>()`
- `fullscreenChange = output<boolean>()`

Delete confirmation, one-level undo and resize scheduling are internal Map
behavior. They add no public input, model or output. Confirmed delete and undo
emit only through the existing controlled `geometry` model; backend persistence
and rollback remain consumer responsibilities.

### Slots

- `ng-template[wblMapInfo]` — contents of the anchored Info dialog.
- `ng-template[wblMapSettings]` — contents of the anchored Settings dialog.
- `ng-template[wblMapLoading]` — optional loading-state content.
- `ng-template[wblMapError]` — optional error-state content with
  `WblMapErrorContext`.

Feature-owned detail cards stay outside this reusable owner.

### Local evidence target

- Code: `src/app/design-system/organisms/map/map.component.ts`.
- Public models: `src/app/design-system/organisms/map/map.models.ts` when the
  implementation separates them from the component.
- Focused test: `src/app/design-system/organisms/map/map.component.spec.ts`.
- Public export: `src/app/design-system/index.ts`.
- Storybook: `storybook/stories/map.stories.ts`.

## Figma evidence

- [Map component, node `10064:27554`](https://www.figma.com/design/EO9tuCsqwuHL8cZ4Eb2psR/?node-id=10064-27554): main/mini variants, control visibility and layout.
- [Map specification, node `5833:13685`](https://www.figma.com/design/EO9tuCsqwuHL8cZ4Eb2psR/?node-id=5833-13685): size usage, zoom interaction, control intent, polygons, pins and lines.
- [Light/dark background examples, node `10054:31330`](https://www.figma.com/design/EO9tuCsqwuHL8cZ4Eb2psR/?node-id=10054-31330): six scale references for each theme.
- [Dark map composition, node `23125:654`](https://www.figma.com/design/EO9tuCsqwuHL8cZ4Eb2psR/?node-id=23125-654): real detailed dark cartography and control placement.
- [Draw action bar, node `14577:5541`](https://www.figma.com/design/EO9tuCsqwuHL8cZ4Eb2psR/?node-id=14577-5541): one surfaced action group with Flat Primary actions.
- [Cursor-hint Tooltip, node `14577:5540`](https://www.figma.com/design/EO9tuCsqwuHL8cZ4Eb2psR/?node-id=14577-5540): verified hint copy and visual treatment.
- [Pointer correction, node `18205:808`](https://www.figma.com/design/EO9tuCsqwuHL8cZ4Eb2psR/?node-id=18205-808): separate Pointer geometry and shadow.

Exact-node metadata, design context, variables and screenshots were read on
2026-09-10. Figma establishes visual intent, not the MapLibre API. Published
component keys were unavailable, so no assembly sidecar is claimed yet.

## Documentation task

M-003 · [GitLab issue](https://gitlab.com/polozovdaniel/logistics-2/-/work_items/39) ·
actor `artem-mokin` / GitLab `26942597` · agent `Specs subagent` ·
run/session `7a124ab4-0ecb-435c-9cb5-dd9df24c995e`.
