mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
refactor v1.9.3: вынести fitView/declump в logic.ts (+юнит-тесты 9→14), задействовать averageLqi, удалить мёртвый код (_edit/_renderEditbar/_applyXY)
This commit is contained in:
@@ -10,7 +10,7 @@ PLANS_DIR = "houseplan/plans" # относительно каталога ко
|
|||||||
FILES_URL = "/houseplan_files/files"
|
FILES_URL = "/houseplan_files/files"
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.9.2"
|
VERSION = "1.9.3"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -15,5 +15,5 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"issue_tracker": "https://github.com/justbusiness/houseplan-card/issues",
|
"issue_tracker": "https://github.com/justbusiness/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "1.9.2"
|
"version": "1.9.3"
|
||||||
}
|
}
|
||||||
Vendored
+42
-56
File diff suppressed because one or more lines are too long
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.9.3 — 2026-07-05 (аудит + рефакторинг + тесты)
|
||||||
|
- Чистые функции `fitView` (contain-прямоугольник вьюпорта) и `declump` (расталкивание значков)
|
||||||
|
вынесены из карточки в `logic.ts` — покрыты юнит-тестами (было 9 → стало 14 тестов).
|
||||||
|
- `_lqiFor` и `_roomLqi` теперь используют общую `averageLqi` (убрано дублирование усреднения).
|
||||||
|
- Удалён мёртвый код после отказа от отдельного режима правки: поле `_edit` (писалось, но не
|
||||||
|
читалось), методы `_renderEditbar` и `_applyXY` (не вызывались).
|
||||||
|
- Поведение не изменилось — только структура и покрытие тестами. Проверено: tsc, сборка,
|
||||||
|
14 фронт-тестов + 10 бэк-тестов зелёные, headless-дымтест (рендер/зум/устройства/declump) чист.
|
||||||
|
|
||||||
## v1.9.2 — 2026-07-05 (сетка вдвое мельче)
|
## v1.9.2 — 2026-07-05 (сетка вдвое мельче)
|
||||||
- Шаг сетки разметки/привязки уменьшен вдвое: `GRID_N` 120 → 240. Позиции устройств и контуры
|
- Шаг сетки разметки/привязки уменьшен вдвое: `GRID_N` 120 → 240. Позиции устройств и контуры
|
||||||
комнат НЕ пересчитываются и не сдвигаются: координаты хранятся нормированными долями и снапятся
|
комнат НЕ пересчитываются и не сдвигаются: координаты хранятся нормированными долями и снапятся
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.9.2",
|
"version": "1.9.3",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
|
"description": "Interactive house plan Lovelace card for Home Assistant (dacha Kirillovskoe)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+10
-95
@@ -10,10 +10,11 @@ import { LitElement, html, svg, css, nothing, TemplateResult, PropertyValues } f
|
|||||||
import { EXCLUDED_DOMAINS, iconFor, DOMAIN_PRIORITY } from './rules';
|
import { EXCLUDED_DOMAINS, iconFor, DOMAIN_PRIORITY } from './rules';
|
||||||
import {
|
import {
|
||||||
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
|
lqiColor, snapToGrid, segKey as segKeyOf, samePoint, pointInPolygon, markerIdForBinding,
|
||||||
|
averageLqi, fitView, declump,
|
||||||
} from './logic';
|
} from './logic';
|
||||||
import './editor';
|
import './editor';
|
||||||
|
|
||||||
const CARD_VERSION = '1.9.2';
|
const CARD_VERSION = '1.9.3';
|
||||||
const LS_KEY = 'houseplan_card_layout_v1';
|
const LS_KEY = 'houseplan_card_layout_v1';
|
||||||
const LS_CFG = 'houseplan_card_cfg_v1'; // кэш серверного конфига+раскладки для мгновенного рендера
|
const LS_CFG = 'houseplan_card_cfg_v1'; // кэш серверного конфига+раскладки для мгновенного рендера
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
@@ -120,7 +121,6 @@ class HouseplanCard extends LitElement {
|
|||||||
private _config?: CardConfig;
|
private _config?: CardConfig;
|
||||||
|
|
||||||
private _space = 'f1';
|
private _space = 'f1';
|
||||||
private _edit = false;
|
|
||||||
private _layout: Record<string, { x: number; y: number; s?: string }> = {};
|
private _layout: Record<string, { x: number; y: number; s?: string }> = {};
|
||||||
private _serverStorage = false;
|
private _serverStorage = false;
|
||||||
private _loadOk = false;
|
private _loadOk = false;
|
||||||
@@ -187,7 +187,6 @@ class HouseplanCard extends LitElement {
|
|||||||
hass: { attribute: false },
|
hass: { attribute: false },
|
||||||
_config: { state: true },
|
_config: { state: true },
|
||||||
_space: { state: true },
|
_space: { state: true },
|
||||||
_edit: { state: true },
|
|
||||||
_layout: { state: true },
|
_layout: { state: true },
|
||||||
_devices: { state: true },
|
_devices: { state: true },
|
||||||
_tip: { state: true },
|
_tip: { state: true },
|
||||||
@@ -567,8 +566,7 @@ class HouseplanCard extends LitElement {
|
|||||||
if (!isNaN(v)) vals.push(v);
|
if (!isNaN(v)) vals.push(v);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!vals.length) return null;
|
return averageLqi(vals);
|
||||||
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _roomLqi(area: string | null): number | null {
|
private _roomLqi(area: string | null): number | null {
|
||||||
@@ -579,8 +577,7 @@ class HouseplanCard extends LitElement {
|
|||||||
const l = this._lqiFor(d.entities);
|
const l = this._lqiFor(d.entities);
|
||||||
if (l != null) vals.push(l);
|
if (l != null) vals.push(l);
|
||||||
}
|
}
|
||||||
if (!vals.length) return null;
|
return averageLqi(vals);
|
||||||
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _tempFor(entIds: string[]): number | null {
|
private _tempFor(entIds: string[]): number | null {
|
||||||
@@ -830,45 +827,13 @@ class HouseplanCard extends LitElement {
|
|||||||
x: b.x + pad + cw * ((i % cols) + 0.5),
|
x: b.x + pad + cw * ((i % cols) + 0.5),
|
||||||
y: b.y + pad + ch * (Math.floor(i / cols) + 0.5),
|
y: b.y + pad + ch * (Math.floor(i / cols) + 0.5),
|
||||||
}));
|
}));
|
||||||
this._declump(pts, b, minDist, pad * 0.5);
|
declump(pts, b, minDist, pad * 0.5);
|
||||||
ds.forEach((d, i) => (map[d.id] = pts[i]));
|
ds.forEach((d, i) => (map[d.id] = pts[i]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Расталкивание значков, чтобы не скучивались (в пределах комнаты). */
|
|
||||||
private _declump(
|
|
||||||
pts: { x: number; y: number }[],
|
|
||||||
b: { x: number; y: number; w: number; h: number },
|
|
||||||
minDist: number,
|
|
||||||
pad: number,
|
|
||||||
): void {
|
|
||||||
if (pts.length < 2) return;
|
|
||||||
const minX = b.x + pad, maxX = b.x + b.w - pad, minY = b.y + pad, maxY = b.y + b.h - pad;
|
|
||||||
for (let it = 0; it < 60; it++) {
|
|
||||||
let moved = false;
|
|
||||||
for (let i = 0; i < pts.length; i++) {
|
|
||||||
for (let j = i + 1; j < pts.length; j++) {
|
|
||||||
const dx = pts[j].x - pts[i].x, dy = pts[j].y - pts[i].y;
|
|
||||||
const dist = Math.hypot(dx, dy) || 0.001;
|
|
||||||
if (dist < minDist) {
|
|
||||||
const push = (minDist - dist) / 2;
|
|
||||||
const ux = dx / dist, uy = dy / dist;
|
|
||||||
pts[i].x -= ux * push; pts[i].y -= uy * push;
|
|
||||||
pts[j].x += ux * push; pts[j].y += uy * push;
|
|
||||||
moved = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const q of pts) {
|
|
||||||
q.x = Math.max(minX, Math.min(maxX, q.x));
|
|
||||||
q.y = Math.max(minY, Math.min(maxY, q.y));
|
|
||||||
}
|
|
||||||
if (!moved) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Позиция устройства в рендер-единицах текущего пространства. */
|
/** Позиция устройства в рендер-единицах текущего пространства. */
|
||||||
private _pos(d: DevItem): { x: number; y: number } {
|
private _pos(d: DevItem): { x: number; y: number } {
|
||||||
const s = this._spaceModel(d.space);
|
const s = this._spaceModel(d.space);
|
||||||
@@ -961,20 +926,9 @@ class HouseplanCard extends LitElement {
|
|||||||
return s && s.clientHeight ? s.clientWidth / s.clientHeight : vb[2] / vb[3];
|
return s && s.clientHeight ? s.clientWidth / s.clientHeight : vb[2] / vb[3];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Прямоугольник «contain» с аспектом сцены, вмещающий весь план (vb). */
|
|
||||||
private _fitView(vb: number[], aspect: number): { x: number; y: number; w: number; h: number } {
|
|
||||||
const planA = vb[2] / vb[3];
|
|
||||||
if (aspect > planA) {
|
|
||||||
const h = vb[3], w = vb[3] * aspect;
|
|
||||||
return { x: vb[0] - (w - vb[2]) / 2, y: vb[1], w, h };
|
|
||||||
}
|
|
||||||
const w = vb[2], h = vb[2] / aspect;
|
|
||||||
return { x: vb[0], y: vb[1] - (h - vb[3]) / 2, w, h };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Текущий view с фолбэком на полный fit. */
|
/** Текущий view с фолбэком на полный fit. */
|
||||||
private _viewOr(vb: number[]): { x: number; y: number; w: number; h: number } {
|
private _viewOr(vb: number[]): { x: number; y: number; w: number; h: number } {
|
||||||
return this._view && this._view.w ? this._view : this._fitView(vb, this._stageAspect());
|
return this._view && this._view.w ? this._view : fitView(vb, this._stageAspect());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Экран (sx,sy относительно сцены, px) → координаты vb по текущему view. */
|
/** Экран (sx,sy относительно сцены, px) → координаты vb по текущему view. */
|
||||||
@@ -1001,7 +955,7 @@ class HouseplanCard extends LitElement {
|
|||||||
/** Установить зум (центр — точка vb cx,cy либо центр текущего view). */
|
/** Установить зум (центр — точка vb cx,cy либо центр текущего view). */
|
||||||
private _applyView(zoom: number, cx?: number, cy?: number): void {
|
private _applyView(zoom: number, cx?: number, cy?: number): void {
|
||||||
const vb = this._spaceModel().vb;
|
const vb = this._spaceModel().vb;
|
||||||
const fit = this._fitView(vb, this._stageAspect());
|
const fit = fitView(vb, this._stageAspect());
|
||||||
const z = Math.min(8, Math.max(1, zoom));
|
const z = Math.min(8, Math.max(1, zoom));
|
||||||
const w = fit.w / z, h = fit.h / z;
|
const w = fit.w / z, h = fit.h / z;
|
||||||
const cur = this._viewOr(vb);
|
const cur = this._viewOr(vb);
|
||||||
@@ -1024,7 +978,7 @@ class HouseplanCard extends LitElement {
|
|||||||
const stage = this._stageEl;
|
const stage = this._stageEl;
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
const vb = this._spaceModel().vb;
|
const vb = this._spaceModel().vb;
|
||||||
const fit = this._fitView(vb, this._stageAspect());
|
const fit = fitView(vb, this._stageAspect());
|
||||||
const z = Math.min(8, Math.max(1, newZoom));
|
const z = Math.min(8, Math.max(1, newZoom));
|
||||||
const w = stage.clientWidth, h = stage.clientHeight;
|
const w = stage.clientWidth, h = stage.clientHeight;
|
||||||
const pt = this._screenToVb(sx, sy);
|
const pt = this._screenToVb(sx, sy);
|
||||||
@@ -1053,7 +1007,7 @@ class HouseplanCard extends LitElement {
|
|||||||
private _resetZoom(): void {
|
private _resetZoom(): void {
|
||||||
const vb = this._spaceModel().vb;
|
const vb = this._spaceModel().vb;
|
||||||
this._zoom = 1;
|
this._zoom = 1;
|
||||||
this._view = this._fitView(vb, this._stageAspect());
|
this._view = fitView(vb, this._stageAspect());
|
||||||
this._saveZoom();
|
this._saveZoom();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,7 +1074,7 @@ class HouseplanCard extends LitElement {
|
|||||||
if (this._zoom > 1 && this._view) {
|
if (this._zoom > 1 && this._view) {
|
||||||
const stage = this._stageEl!;
|
const stage = this._stageEl!;
|
||||||
const v = this._view;
|
const v = this._view;
|
||||||
const fit = this._fitView(this._spaceModel().vb, this._stageAspect());
|
const fit = fitView(this._spaceModel().vb, this._stageAspect());
|
||||||
this._view = this._clampView(
|
this._view = this._clampView(
|
||||||
{
|
{
|
||||||
x: this._panStart.vx - (ddx / stage.clientWidth) * v.w,
|
x: this._panStart.vx - (ddx / stage.clientWidth) * v.w,
|
||||||
@@ -1184,17 +1138,6 @@ class HouseplanCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _applyXY(axis: 'x' | 'y', val: string): void {
|
|
||||||
if (!this._selId) return;
|
|
||||||
const n = parseFloat(val);
|
|
||||||
if (isNaN(n)) return;
|
|
||||||
const d = this._devices.find((x) => x.id === this._selId);
|
|
||||||
if (!d || d.virtual) return;
|
|
||||||
const p = { ...this._pos(d) };
|
|
||||||
p[axis] = n;
|
|
||||||
this._savePos(d, p.x, p.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _resetLayout(): void {
|
private _resetLayout(): void {
|
||||||
if (!confirm('Сбросить позиции всех иконок к авто-раскладке?')) return;
|
if (!confirm('Сбросить позиции всех иконок к авто-раскладке?')) return;
|
||||||
this._layout = {};
|
this._layout = {};
|
||||||
@@ -1242,7 +1185,6 @@ class HouseplanCard extends LitElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._markup = !this._markup;
|
this._markup = !this._markup;
|
||||||
this._edit = false;
|
|
||||||
this._path = [];
|
this._path = [];
|
||||||
this._cursorPt = null;
|
this._cursorPt = null;
|
||||||
this._tool = 'draw';
|
this._tool = 'draw';
|
||||||
@@ -1818,7 +1760,6 @@ class HouseplanCard extends LitElement {
|
|||||||
this._maybeRebuildDevices();
|
this._maybeRebuildDevices();
|
||||||
if (wasFirst) {
|
if (wasFirst) {
|
||||||
// ведём пользователя дальше: сразу в режим разметки комнат
|
// ведём пользователя дальше: сразу в режим разметки комнат
|
||||||
this._edit = false;
|
|
||||||
this._markup = true;
|
this._markup = true;
|
||||||
this._tool = 'draw';
|
this._tool = 'draw';
|
||||||
this._path = [];
|
this._path = [];
|
||||||
@@ -2339,32 +2280,6 @@ class HouseplanCard extends LitElement {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _renderEditbar(): TemplateResult {
|
|
||||||
const sel = this._selId ? this._devices.find((d) => d.id === this._selId) : null;
|
|
||||||
const p = sel ? this._pos(sel) : null;
|
|
||||||
return html`<div class="editbar">
|
|
||||||
<ha-icon icon="mdi:cursor-move" class="warn"></ha-icon>
|
|
||||||
<span class="sname">${sel ? sel.name : 'Режим правки — тащите иконки мышью'}</span>
|
|
||||||
${sel && p
|
|
||||||
? html`<label>X</label><input type="number" .value=${String(Math.round(p.x))}
|
|
||||||
@change=${(e: Event) => this._applyXY('x', (e.target as HTMLInputElement).value)} />
|
|
||||||
<label>Y</label><input type="number" .value=${String(Math.round(p.y))}
|
|
||||||
@change=${(e: Event) => this._applyXY('y', (e.target as HTMLInputElement).value)} />`
|
|
||||||
: nothing}
|
|
||||||
<span class="spacer"></span>
|
|
||||||
<span class="hint">
|
|
||||||
${this._serverStorage
|
|
||||||
? this._norm
|
|
||||||
? 'конфиг и раскладка: сервер'
|
|
||||||
: 'раскладка: сервер · конфиг: встроенный'
|
|
||||||
: 'сохранение: этот браузер'}
|
|
||||||
</span>
|
|
||||||
<button class="btn ghost" @click=${this._resetLayout} title="Сбросить всё">
|
|
||||||
<ha-icon icon="mdi:backup-restore"></ha-icon>
|
|
||||||
</button>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
--hp-bg: var(--card-background-color, #16212e);
|
--hp-bg: var(--card-background-color, #16212e);
|
||||||
|
|||||||
@@ -55,3 +55,46 @@ export function averageLqi(values: number[]): number | null {
|
|||||||
if (!values.length) return null;
|
if (!values.length) return null;
|
||||||
return Math.round(values.reduce((a, b) => a + b, 0) / values.length);
|
return Math.round(values.reduce((a, b) => a + b, 0) / values.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Прямоугольник «contain» с заданным аспектом (w/h), вмещающий весь vb [x,y,w,h]. */
|
||||||
|
export function fitView(vb: number[], aspect: number): { x: number; y: number; w: number; h: number } {
|
||||||
|
const planA = vb[2] / vb[3];
|
||||||
|
if (aspect > planA) {
|
||||||
|
const h = vb[3], w = vb[3] * aspect;
|
||||||
|
return { x: vb[0] - (w - vb[2]) / 2, y: vb[1], w, h };
|
||||||
|
}
|
||||||
|
const w = vb[2], h = vb[2] / aspect;
|
||||||
|
return { x: vb[0], y: vb[1] - (h - vb[3]) / 2, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Расталкивание точек: не ближе minDist друг к другу, в пределах прямоугольника b с отступом pad. Мутирует pts. */
|
||||||
|
export function declump(
|
||||||
|
pts: { x: number; y: number }[],
|
||||||
|
b: { x: number; y: number; w: number; h: number },
|
||||||
|
minDist: number,
|
||||||
|
pad: number,
|
||||||
|
): void {
|
||||||
|
if (pts.length < 2) return;
|
||||||
|
const minX = b.x + pad, maxX = b.x + b.w - pad, minY = b.y + pad, maxY = b.y + b.h - pad;
|
||||||
|
for (let it = 0; it < 60; it++) {
|
||||||
|
let moved = false;
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
for (let j = i + 1; j < pts.length; j++) {
|
||||||
|
const dx = pts[j].x - pts[i].x, dy = pts[j].y - pts[i].y;
|
||||||
|
const dist = Math.hypot(dx, dy) || 0.001;
|
||||||
|
if (dist < minDist) {
|
||||||
|
const push = (minDist - dist) / 2;
|
||||||
|
const ux = dx / dist, uy = dy / dist;
|
||||||
|
pts[i].x -= ux * push; pts[i].y -= uy * push;
|
||||||
|
pts[j].x += ux * push; pts[j].y += uy * push;
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const q of pts) {
|
||||||
|
q.x = Math.max(minX, Math.min(maxX, q.x));
|
||||||
|
q.y = Math.max(minY, Math.min(maxY, q.y));
|
||||||
|
}
|
||||||
|
if (!moved) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||||
|
fitView, declump,
|
||||||
} from '../test-build/logic.js';
|
} from '../test-build/logic.js';
|
||||||
import { iconFor } from '../test-build/rules.js';
|
import { iconFor } from '../test-build/rules.js';
|
||||||
|
|
||||||
@@ -67,3 +68,47 @@ test('iconFor: ключевые правила', () => {
|
|||||||
assert.equal(iconFor('Термоголовка', 'Aqara'), 'mdi:radiator');
|
assert.equal(iconFor('Термоголовка', 'Aqara'), 'mdi:radiator');
|
||||||
assert.equal(iconFor('Неизвестное', 'XYZ'), 'mdi:chip');
|
assert.equal(iconFor('Неизвестное', 'XYZ'), 'mdi:chip');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('fitView: портретный план в широкой сцене — по бокам поля, весь план внутри', () => {
|
||||||
|
// vb 100x200 (аспект 0.5), сцена аспект 2 → view шире плана, высота = 200
|
||||||
|
const v = fitView([0, 0, 100, 200], 2);
|
||||||
|
assert.equal(v.h, 200);
|
||||||
|
assert.equal(v.w, 400); // 200*2
|
||||||
|
assert.equal(v.x, -150); // (100-400)/2 центрирование
|
||||||
|
assert.equal(v.y, 0);
|
||||||
|
// весь план внутри view
|
||||||
|
assert.ok(v.x <= 0 && v.x + v.w >= 100 && v.y <= 0 && v.y + v.h >= 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fitView: аспект сцены совпадает с планом — view == vb', () => {
|
||||||
|
const v = fitView([10, 20, 300, 150], 2); // планA = 2 == аспект
|
||||||
|
assert.equal(v.x, 10); assert.equal(v.y, 20); assert.equal(v.w, 300); assert.equal(v.h, 150);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declump: близкие точки расходятся не ближе minDist и остаются в границах', () => {
|
||||||
|
const b = { x: 0, y: 0, w: 100, h: 100 };
|
||||||
|
const pts = [ { x: 50, y: 50 }, { x: 51, y: 50 }, { x: 50, y: 51 } ];
|
||||||
|
declump(pts, b, 20, 5);
|
||||||
|
// все пары не ближе ~minDist (с допуском на кламп к границам)
|
||||||
|
for (let i = 0; i < pts.length; i++)
|
||||||
|
for (let j = i + 1; j < pts.length; j++) {
|
||||||
|
const d = Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y);
|
||||||
|
assert.ok(d > 12, `пара ${i},${j} слишком близко: ${d}`);
|
||||||
|
}
|
||||||
|
// в границах [5..95]
|
||||||
|
for (const q of pts) {
|
||||||
|
assert.ok(q.x >= 5 && q.x <= 95 && q.y >= 5 && q.y <= 95);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declump: одна точка не двигается', () => {
|
||||||
|
const pts = [{ x: 30, y: 40 }];
|
||||||
|
declump(pts, { x: 0, y: 0, w: 100, h: 100 }, 20, 5);
|
||||||
|
assert.deepEqual(pts, [{ x: 30, y: 40 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('averageLqi: пусто → null, иначе округлённое среднее', () => {
|
||||||
|
assert.equal(averageLqi([]), null);
|
||||||
|
assert.equal(averageLqi([100, 200]), 150);
|
||||||
|
assert.equal(averageLqi([1, 2, 2]), 2);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user