mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 22:29:05 +00:00
feat(card): the LED strip tool in the Devices editor (#780)
Stage 4 of #780 (ТЗ §4–§5). «LED strip» next to «Add» draws a chain with clean clicks only (pan, pinch, a second finger, cancel and the synthetic click after navigation add nothing), snaps to the grid and to physical wall faces / zero-wall axes, stops at the first face of masonry, partitions, columns and windows (doors, gates and passages are cut by geometry), Shift gives 45°. Ctrl+Z removes the chain's own point first, Esc finishes, a double click or a click on the first point (≥3 vertices) closes; fewer than two distinct points write nothing. A finished strip opens the device picker (lights first, taken markers explained, «New device…» binds in that dialog's own write, «Later» keeps unbound geometry). A selected strip shows its vertex handles (a drag re-checks both neighbours and the whole path) and a new Devices tray branch: device settings, bind/change, unbind, show as icon, delete. The device dialog gets the representation section: show as strip (restores a hidden shape at once or draws one for this marker, behind the dialog's own save/discard guard), show as icon, unbind/delete for a hidden shape. Every geometry or representation change is one optimistic write and one LED command of the device history; Undo/Redo restores only its own strip record and refuses when a newer change sits on it. A rebinding renames the link in the marker save, a deleted marker leaves an unbound strip, a bound marker may not move to another space. Plan/Background show strips as a passive translucent mark. The tool, its `led` dictionary (en static, ru/de/fr lazy) and placement geometry are a new lazy `led-strip-editor` chunk (9.5 KB gzip), loaded only on the tool, an editable strip in the shown space or a strip device's dialog. The initial graph gets the loader and delegation only (src/led-strip-card.ts); the lazy editor graph +122 B, inside its ceiling. Mutant anchors follow the moved code (marker dialog guard, static LED layer). Issue: #780 User-Visible: no
This commit is contained in:
@@ -427,6 +427,7 @@ const GRAPH_LABELS = {
|
||||
lazyEditorFiles: 'lazy editor graph',
|
||||
lazyOnboardingFiles: 'lazy onboarding graph',
|
||||
lazyNamespaceLocaleFiles: 'lazy namespace locale graph',
|
||||
lazyLedEditorFiles: 'lazy LED editor graph',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -470,6 +471,7 @@ export const NAMESPACE_ENGLISH_CONSUMERS = {
|
||||
support: ['lazyEditorFiles'],
|
||||
topology: ['lazyEditorFiles'],
|
||||
tools: ['lazyEditorFiles'],
|
||||
led: ['lazyLedEditorFiles'],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -776,7 +778,7 @@ export function assertBundleBudget(
|
||||
const namespaceLocales = manifest.lazyNamespaceLocaleFiles;
|
||||
if (!Array.isArray(namespaceLocales) || namespaceLocales.length !== NAMESPACE_LOCALE_CHUNKS.length) {
|
||||
throw new Error(`lazy namespace locale graph has ${namespaceLocales?.length ?? 0} files,`
|
||||
+ ` expected ${NAMESPACE_LOCALE_CHUNKS.length} (settings/support/topology/tools × ru/de/fr)`);
|
||||
+ ` expected ${NAMESPACE_LOCALE_CHUNKS.length} (settings/support/topology/tools/led × ru/de/fr)`);
|
||||
}
|
||||
for (const [graph, label] of [
|
||||
['initialViewFiles', 'initial View graph'],
|
||||
|
||||
@@ -14,13 +14,14 @@ const PDF_RETRY_ASSET_TOKEN = '__HOUSEPLAN_PDF_RETRY_ASSET__';
|
||||
const MOON_RETRY_ASSET_TOKEN = '__HOUSEPLAN_MOON_RETRY_ASSET__';
|
||||
const LED_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_RETRY_ASSET__';
|
||||
const LED_FIELD_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_FIELD_RETRY_ASSET__';
|
||||
const LED_EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_LED_EDITOR_RETRY_ASSET__';
|
||||
|
||||
/**
|
||||
* #627: ru/de/fr of the three lazy dictionary namespaces are one chunk per
|
||||
* namespace × language. Their loader modules are the recognition marker for
|
||||
* the manifest graph and the target of each second-attempt retry token.
|
||||
*/
|
||||
export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology', 'tools']
|
||||
export const NAMESPACE_LOCALE_CHUNKS = ['settings', 'support', 'topology', 'tools', 'led']
|
||||
.flatMap((namespace) => ['ru', 'de', 'fr'].map((language) => ({
|
||||
namespace,
|
||||
language,
|
||||
@@ -124,6 +125,9 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
// strip is on in a Glow room with a light scene.
|
||||
: modules.some((id) => id.endsWith('/src/led-strip-field.ts'))
|
||||
? 'led-field'
|
||||
// #780: the Devices-editor LED tool, loaded by the editor runtime.
|
||||
: modules.some((id) => id.endsWith('/src/led-strip-editor.ts'))
|
||||
? 'led-editor'
|
||||
// #474: designer furniture artwork — its own lazy chunk, shared by
|
||||
// the editor (static import) and the View runtime (dynamic).
|
||||
: modules.some((id) => id.endsWith('/src/furniture-plan-art.generated.ts'))
|
||||
@@ -176,6 +180,7 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
|| path.includes('led-strip-runtime-')),
|
||||
...files.filter((file) => file._role === 'led-field').map((file) => file.path),
|
||||
];
|
||||
const ledEditorRoots = files.filter((file) => file._role === 'led-editor').map((file) => file.path);
|
||||
// #627: namespace dictionaries are dynamic imports of LAZY chunks, never of
|
||||
// the initial graph, so they are found by their module, not as a root.
|
||||
const namespaceLocaleRoots = files.filter((file) => file._role === 'namespace-locale')
|
||||
@@ -195,6 +200,7 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
const lazyPdf = graphFrom(pdfRoots);
|
||||
const lazyMoon = graphFrom(moonRoots);
|
||||
const lazyLed = graphFrom(ledRoots);
|
||||
const lazyLedEditor = graphFrom(ledEditorRoots);
|
||||
const lazyNamespaceLocale = graphFrom(namespaceLocaleRoots);
|
||||
const sum = (paths) => [...paths]
|
||||
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
|
||||
@@ -227,6 +233,8 @@ export function buildBundleManifest(bundle, fingerprint) {
|
||||
lazyMoonGzipBytes: sum(lazyMoon),
|
||||
lazyLedFiles: [...lazyLed].sort(),
|
||||
lazyLedGzipBytes: sum(lazyLed),
|
||||
lazyLedEditorFiles: [...lazyLedEditor].sort(),
|
||||
lazyLedEditorGzipBytes: sum(lazyLedEditor),
|
||||
lazyNamespaceLocaleFiles: [...lazyNamespaceLocale].sort(),
|
||||
lazyNamespaceLocaleGzipBytes: sum(lazyNamespaceLocale),
|
||||
files: files.map(({ _role, ...file }) => file),
|
||||
@@ -302,6 +310,9 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
const ledField = chunks.find((chunk) => Object.keys(chunk.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-field.ts')));
|
||||
if (!ledField) throw new Error('LED strip field chunk was not emitted');
|
||||
const ledEditor = chunks.find((chunk) => Object.keys(chunk.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith('/src/led-strip-editor.ts')));
|
||||
if (!ledEditor) throw new Error('LED strip editor chunk was not emitted');
|
||||
const namespaceLocales = NAMESPACE_LOCALE_CHUNKS.map((entry) => {
|
||||
const chunk = chunks.find((candidate) => Object.keys(candidate.modules)
|
||||
.some((id) => id.replaceAll('\\', '/').endsWith(entry.module)));
|
||||
@@ -320,6 +331,7 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
let moonReplacements = 0;
|
||||
let ledReplacements = 0;
|
||||
let ledFieldReplacements = 0;
|
||||
let ledEditorReplacements = 0;
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
|
||||
@@ -375,6 +387,12 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
ledReplacements += chunk.code.split(LED_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(LED_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
if (chunk.code.includes(LED_EDITOR_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), ledEditor.fileName);
|
||||
if (!asset.startsWith('.')) asset = `./${asset}`;
|
||||
ledEditorReplacements += chunk.code.split(LED_EDITOR_RETRY_ASSET_TOKEN).length - 1;
|
||||
chunk.code = chunk.code.replaceAll(LED_EDITOR_RETRY_ASSET_TOKEN, asset);
|
||||
}
|
||||
if (chunk.code.includes(LED_FIELD_RETRY_ASSET_TOKEN)) {
|
||||
let asset = posix.relative(posix.dirname(chunk.fileName), ledField.fileName);
|
||||
if (!asset.startsWith('.')) asset = `./${asset}`;
|
||||
@@ -392,9 +410,9 @@ export function editorRuntimeRetryUrlPlugin() {
|
||||
if (editorReplacements !== 1 || onboardingReplacements !== 1 || isometricReplacements !== 1
|
||||
|| germanReplacements !== 1 || frenchReplacements !== 1 || furnitureArtReplacements !== 1
|
||||
|| pdfReplacements !== 1 || moonReplacements !== 1 || ledReplacements !== 1
|
||||
|| ledFieldReplacements !== 1) {
|
||||
|| ledFieldReplacements !== 1 || ledEditorReplacements !== 1) {
|
||||
throw new Error('lazy retry URL placeholder counts are '
|
||||
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}/${ledReplacements}/${ledFieldReplacements}, expected 1/1/1/1/1/1/1/1/1/1`);
|
||||
+ `${editorReplacements}/${onboardingReplacements}/${isometricReplacements}/${germanReplacements}/${frenchReplacements}/${furnitureArtReplacements}/${pdfReplacements}/${moonReplacements}/${ledReplacements}/${ledFieldReplacements}/${ledEditorReplacements}, expected 1/1/1/1/1/1/1/1/1/1/1`);
|
||||
}
|
||||
// #627: the same strict rule for every namespace × language token —
|
||||
// exactly one second-attempt URL each, never zero and never two.
|
||||
|
||||
@@ -6084,9 +6084,9 @@ const MUTANT_DEFINITIONS = [
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/validation.py',
|
||||
find: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n'
|
||||
+ ' ),\n canonicalize_config_geometry,\n _config_wall_segment_invariants,\n)',
|
||||
+ ' ),\n canonicalize_config_geometry,\n _config_wall_segment_invariants,\n',
|
||||
replace: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n'
|
||||
+ ' ),\n lambda value: value,\n _config_wall_segment_invariants,\n)',
|
||||
+ ' ),\n lambda value: value,\n _config_wall_segment_invariants,\n',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -7387,7 +7387,7 @@ const MUTANT_DEFINITIONS = [
|
||||
find: ' ${passageGlowTunnels}\n'
|
||||
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
|
||||
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
|
||||
+ ' ${glowPools}\n ${wallUnion',
|
||||
+ ' ${glowPools}\n ${leds.size',
|
||||
replace: ' ${passageGlowTunnels}\n'
|
||||
+ ' ${!space.bg && !disp.showNames ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
|
||||
+ ' const center = roomCenter(room);\n'
|
||||
@@ -7397,7 +7397,7 @@ const MUTANT_DEFINITIONS = [
|
||||
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
|
||||
+ ' <g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>\n'
|
||||
+ ' ${glowPools}\n'
|
||||
+ ' ${wallUnion',
|
||||
+ ' ${leds.size',
|
||||
}],
|
||||
},
|
||||
{
|
||||
@@ -13741,8 +13741,9 @@ const MUTANT_DEFINITIONS = [
|
||||
+ 'Перевёрнутое условие закрывает «грязный» диалог молча и спрашивает у чистого',
|
||||
patches: [{
|
||||
file: 'src/editors/marker-dialog.ts',
|
||||
find: " if (!edit || !dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }",
|
||||
replace: " if (!edit || dirty) { forgetMarkerBaseline(this.host); this._closeMarkerDialog(); return; }",
|
||||
// #780: the guard is the dialog's shared `leave` (close and the LED representation switch).
|
||||
find: " if (edit && dirty && !await this.host._confirmDanger({",
|
||||
replace: " if ((!edit || !dirty) && !await this.host._confirmDanger({",
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user