Files
houseplan-card/src/stairs-view.ts
T
Sergey Matyunin d03a68b88a feat: добавить лестницы между этажами (#663)
Прямые и винтовые лестницы получили отдельную модель, инструменты редактора, безопасную межэтажную навигацию, вычитание из чистой площади и плоское отображение в 2.5D.

Issue: #663
User-Visible: yes
2026-09-26 21:00:43 +03:00

98 lines
3.6 KiB
TypeScript

import { nothing, svg, type TemplateResult } from 'lit';
import {
cachedStairRenderGeometry, stairList, type Stair,
} from './stairs';
import { stairTargetState } from './stairs-editor-model';
import type { SpaceModel } from './types';
export interface StairViewHostPort {
_mode: 'view' | 'plan' | 'devices' | 'decor';
_curSpaceCfg: unknown;
_model: SpaceModel[];
_space: string;
_hasFixedFloor: boolean;
_suppressClick: boolean;
_cellCm: number;
_tabClick(spaceId: string): void;
_t(key: 'markup.stairs'): string;
}
type ViewPress = { pid: number; startedAt: number };
/** Eager, read-only stair surface. All editing stays in the lazy editor graph. */
export class StairViewRuntime {
private press: ViewPress | null = null;
private suppressClick = false;
public constructor(private readonly owner: StairViewHostPort) {}
private get stairs(): Stair[] {
return stairList((this.owner._curSpaceCfg as { stairs?: unknown } | null)?.stairs);
}
public pointerDown(event: PointerEvent): void {
this.suppressClick = false;
if (event.pointerType === 'touch') {
this.press = { pid: event.pointerId, startedAt: performance.now() };
}
}
public pointerUp(event: PointerEvent): void {
const press = this.press;
if (!press || press.pid !== event.pointerId) return;
this.press = null;
if (performance.now() - press.startedAt >= 600) this.suppressClick = true;
}
public pointerCancel(event: PointerEvent): void {
if (this.press?.pid !== event.pointerId) return;
this.press = null;
this.suppressClick = true;
}
public clearGesture(): void {
this.press = null;
this.suppressClick = false;
}
public renderLayer(): TemplateResult {
const spaceIds = new Set(this.owner._model.map((item) => item.id));
const interactive = this.owner._mode === 'view';
const items = this.stairs.map((stair) => {
const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm);
const outline = geometry.outline.map((point) => point.join(',')).join(' ');
const targetState = stairTargetState(
stair, this.owner._space, spaceIds, this.owner._hasFixedFloor,
);
const active = interactive && targetState === 'active';
const navigate = (event: Event): void => {
event.stopPropagation();
if (event.type === 'keydown') this.suppressClick = false;
if (!active || this.owner._suppressClick || this.suppressClick
|| !stair.target_space_id) return;
this.owner._tabClick(stair.target_space_id);
};
return svg`<g class="hp-stair ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
data-target-state=${targetState}
role=${active ? 'link' : 'img'} tabindex=${active ? '0' : nothing}
aria-label=${this.owner._t('markup.stairs')}
@click=${navigate}
@keydown=${(event: KeyboardEvent) => {
if (active && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
navigate(event);
}
}}>
<polygon class="hp-stair-outline" points=${outline}></polygon>
<polygon class="hp-stair-hit" points=${outline}
@pointerdown=${(event: PointerEvent) => this.pointerDown(event)}></polygon>
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
</g>`;
});
return svg`<g class="hp-stairs-layer">${items}</g>` as unknown as TemplateResult;
}
}