task(E1): seating-row/block drag interaction with live preview and options

This commit is contained in:
Agent Zero
2026-08-27 22:46:51 +02:00
parent b875012afd
commit 86b1e0b8db
2 changed files with 208 additions and 17 deletions
+138 -15
View File
@@ -1,19 +1,31 @@
/** /**
* event-tools Built-in V2-Plugin (Task A4.9). * event-tools Built-in V2-Plugin (Tasks A4.9 + E1).
* *
* Fachwerkzeuge für Veranstaltungsplanung: chair, seating-row, * Fachwerkzeuge für Veranstaltungsplanung: chair (Einzelnklick),
* seating-block, table, stage — alle Einzelklick-Platzierer über * seating-row + seating-block (Drag-Interaktion mit Live-Preview
* den (durch BUG-1-Fix korrigierten) SeatingService. * und Options-Schema), table + stage (Einzelnklick).
* *
* Jede Platzierung = doc.transact → 1 Undo-Schritt (Multi-Element-Sets * Jede Platzierung = doc.transact → 1 Undo-Schritt (Multi-Element-Sets
* wie seating-block erscheinen/verschwinden gemeinsam). * wie seating-block erscheinen/verschwinden gemeinsam).
*
* SeatingService ist durch BUG-1-Fix deterministisch (rowIds unique).
*/ */
import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types'; import type { PluginV2, ToolExtensionV2, FullToolContext } from '../../types';
import type { CADElement } from '../../../types/cad.types'; import type { CADElement } from '../../../types/cad.types';
import type { Pt } from '../../../tools/modification/geometry';
import { SeatingService } from '../../../services/seatingService'; import { SeatingService } from '../../../services/seatingService';
const seating = new SeatingService(); const seating = new SeatingService();
// ─── Drag-Sitzungs-State für row/block ─────────────────────
let dragStart: Pt | null = null;
function optsOf(ctx: FullToolContext): { layerId: string } {
return {
layerId: (ctx.options.layerId as string | undefined) ?? 'layer-0',
};
}
/** Erzeugt ein Einzelklick-Platzierer-Werkzeug über einen placer. */ /** Erzeugt ein Einzelklick-Platzierer-Werkzeug über einen placer. */
function makePlaceTool( function makePlaceTool(
id: string, id: string,
@@ -36,7 +48,7 @@ function makePlaceTool(
down(e, ctx) { down(e, ctx) {
const c = ctx as FullToolContext; const c = ctx as FullToolContext;
if (!c.doc) return; if (!c.doc) return;
const layerId = (c.options.layerId as string | undefined) ?? 'layer-0'; const { layerId } = optsOf(ctx);
const placed = placer(e.world.x, e.world.y, layerId); const placed = placer(e.world.x, e.world.y, layerId);
const els = Array.isArray(placed) ? placed : [placed]; const els = Array.isArray(placed) ? placed : [placed];
c.doc.transact(() => { c.doc.transact(() => {
@@ -48,31 +60,142 @@ function makePlaceTool(
}; };
} }
/**
* seating-row (Task E1): Klick1 = Start, Drag = Länge/Vorschau,
* up = Commit. Anzahl Stühle wird aus Distanz berechnet (spacing aus Options).
*/
export const seatingRowToolDrag: ToolExtensionV2 = {
manifest: {
id: 'seating-row',
label: 'Stuhldreihe',
icon: '\u2500\u2570\u2500',
ribbonTab: 'insert',
tags: ['basic'],
description: 'Reihe: Start klicken, Ende ziehen. Stuhlzahl aus Länge.',
},
optionsSchema: [
{ key: 'spacing', label: 'Abstand (cm)', type: 'number', min: 20, max: 200 },
],
handlers: {
down(e, ctx) {
dragStart = e.world;
ctx.setStatus('Reihe: Endpunkt ziehen');
},
move(e, ctx) {
if (!dragStart) return;
const c = ctx as FullToolContext;
const { layerId } = optsOf(c);
const spacing = (c.options.spacing as number | undefined) ?? 50;
const length = Math.hypot(e.world.x - dragStart.x, e.world.y - dragStart.y);
const count = Math.max(2, Math.floor(length / spacing));
const preview = seating.createSeatingRow(dragStart.x, dragStart.y, layerId, { count, spacing });
c.setPreview?.(preview[0] ?? null); // Legacy-Vorschau zeigt ersten Stuhl als Referenz
ctx.setStatus(`Reihe: ${count} Stühle (${length.toFixed(0)}mm)`);
},
up(e, ctx) {
const c = ctx as FullToolContext;
if (!dragStart || !c.doc) { dragStart = null; return; }
const { layerId } = optsOf(c);
const spacing = (c.options.spacing as number | undefined) ?? 50;
const length = Math.hypot(e.world.x - dragStart.x, e.world.y - dragStart.y);
const count = Math.max(2, Math.floor(length / spacing));
const els = seating.createSeatingRow(dragStart.x, dragStart.y, layerId, { count, spacing });
c.doc.transact(() => {
for (const el of els) c.doc!.addElement(el);
});
ctx.setStatus(`Reihe: ${count} Stühle platziert`);
dragStart = null;
c.setPreview?.(null);
},
cancel(ctx) {
dragStart = null;
ctx.setPreview?.(null);
ctx.setStatus('Reihe abgebrochen');
},
},
};
/**
* seating-block (Task E1): Klick1 = Start-Ecke, Drag = Block-Dimension,
* up = Commit. rows/cols aus Distanz + spacing berechnet.
*/
export const seatingBlockToolDrag: ToolExtensionV2 = {
manifest: {
id: 'seating-block',
label: 'Stuhlblock',
icon: '\u2565',
ribbonTab: 'insert',
tags: ['basic'],
description: 'Block: Start-Ecke klicken, Gegenecke ziehen.',
},
optionsSchema: [
{ key: 'colSpacing', label: 'Sitzabstand X (cm)', type: 'number', min: 30, max: 200 },
{ key: 'rowSpacing', label: 'Reihenabstand Y (cm)', type: 'number', min: 30, max: 200 },
],
handlers: {
down(e, ctx) {
dragStart = e.world;
ctx.setStatus('Block: Gegenecke ziehen');
},
move(e, ctx) {
if (!dragStart) return;
const c = ctx as FullToolContext;
const { layerId } = optsOf(c);
const colSpacing = (c.options.colSpacing as number | undefined) ?? 50;
const rowSpacing = (c.options.rowSpacing as number | undefined) ?? 50;
const w = Math.abs(e.world.x - dragStart.x);
const h = Math.abs(e.world.y - dragStart.y);
const cols = Math.max(2, Math.floor(w / colSpacing));
const rows = Math.max(2, Math.floor(h / rowSpacing));
ctx.setStatus(`Block: ${rows} Reihen × ${cols} Stühle = ${rows * cols} Sitzplätze`);
},
up(e, ctx) {
const c = ctx as FullToolContext;
if (!dragStart || !c.doc) { dragStart = null; return; }
const { layerId } = optsOf(c);
const colSpacing = (c.options.colSpacing as number | undefined) ?? 50;
const rowSpacing = (c.options.rowSpacing as number | undefined) ?? 50;
const w = Math.abs(e.world.x - dragStart.x);
const h = Math.abs(e.world.y - dragStart.y);
const cols = Math.max(2, Math.floor(w / colSpacing));
const rows = Math.max(2, Math.floor(h / rowSpacing));
const centerX = (dragStart.x + e.world.x) / 2;
const centerY = (dragStart.y + e.world.y) / 2;
const els = seating.createSeatingBlock(centerX, centerY, layerId, { rows, cols, colSpacing, rowSpacing });
c.doc.transact(() => {
for (const el of els) c.doc!.addElement(el);
});
ctx.setStatus(`Block: ${rows}×${cols} = ${els.length} Stühle platziert`);
dragStart = null;
c.setPreview?.(null);
},
cancel(ctx) {
dragStart = null;
ctx.setPreview?.(null);
ctx.setStatus('Block abgebrochen');
},
},
};
export const chairTool = makePlaceTool('chair', 'Stuhl', '\u2570', export const chairTool = makePlaceTool('chair', 'Stuhl', '\u2570',
(x, y, layerId) => seating.createChair(x, y, layerId), 'Stuhl'); (x, y, layerId) => seating.createChair(x, y, layerId), 'Stuhl');
export const seatingRowTool = makePlaceTool('seating-row', 'Stuhldreihe', '\u2500\u2570\u2500',
(x, y, layerId) => seating.createSeatingRow(x, y, layerId), 'Reihe');
export const seatingBlockTool = makePlaceTool('seating-block', 'Stuhlblock', '\u2565',
(x, y, layerId) => seating.createSeatingBlock(x, y, layerId), 'Block');
export const tableTool = makePlaceTool('table', 'Tisch', '\u25cb', export const tableTool = makePlaceTool('table', 'Tisch', '\u25cb',
(x, y, layerId) => seating.createTable(x, y, layerId), 'Tisch'); (x, y, layerId) => seating.createTable(x, y, layerId), 'Tisch');
export const stageTool = makePlaceTool('stage', 'Bühne', '\u25ad', export const stageTool = makePlaceTool('stage', 'Bühne', '\u25ad',
(x, y, layerId) => seating.createStage(x, y, layerId), 'Bühne'); (x, y, layerId) => seating.createStage(x, y, layerId), 'Bühne');
/** V2-Plugin: Event-Domain (Task A4.9). */ /** V2-Plugin: Event-Domain (Tasks A4.9 + E1). */
export const eventToolsV2Plugin: PluginV2 = { export const eventToolsV2Plugin: PluginV2 = {
manifest: { manifest: {
id: 'event-tools', id: 'event-tools',
name: 'Event-Bestuhlung', name: 'Event-Bestuhlung',
version: '2.0.0', version: '2.1.0',
author: 'web-cad team', author: 'web-cad team',
description: 'Stühle, Reihen, Blöcke, Tische, Bühne (V2).', description: 'Stühle, Reihen (Drag), Blöcke (Drag), Tische, Bühne (V2).',
category: 'elements', category: 'elements',
enabledByDefault: true, enabledByDefault: true,
}, },
tools: [chairTool, seatingRowTool, seatingBlockTool, tableTool, stageTool], tools: [chairTool, seatingRowToolDrag, seatingBlockToolDrag, tableTool, stageTool],
}; };
+70 -2
View File
@@ -370,14 +370,16 @@ describe('A4.9 pilot: event tools', () => {
const ctx = makeCtx({ doc }); const ctx = makeCtx({ doc });
const block = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-block')!; const block = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-block')!;
block.handlers.down!(pe(0, 0), ctx); // Legacy-Default: rows=3, cols=8 // E1: Drag-Interaktion (down + up) mit Distanz 200×100, spacing 50
block.handlers.down!(pe(0, 0), ctx);
block.handlers.up!(pe(200, 100), ctx); // 4 cols × 2 rows = 8 Stühle
const all = doc.getAllElements(); const all = doc.getAllElements();
expect(all.length).toBeGreaterThan(1); // Multi-Element-Satz expect(all.length).toBeGreaterThan(1); // Multi-Element-Satz
expect(all.every((e) => e.type === 'chair')).toBe(true); expect(all.every((e) => e.type === 'chair')).toBe(true);
// BUG-1-Regressionsschutz: alle Reihen im Block haben eigene rowIds // BUG-1-Regressionsschutz: alle Reihen im Block haben eigene rowIds
const rowIds = new Set(all.map((e) => e.properties.rowId)); const rowIds = new Set(all.map((e) => e.properties.rowId));
expect(rowIds.size).toBeGreaterThanOrEqual(3); expect(rowIds.size).toBe(2); // Drag 100mm bei spacing 50 → 2 Reihen
// Kern-Akzeptanz: EIN undo entfernt den GANZEN Block // Kern-Akzeptanz: EIN undo entfernt den GANZEN Block
expect(doc.undo()).toBe(true); expect(doc.undo()).toBe(true);
@@ -390,7 +392,9 @@ describe('A4.9 pilot: event tools', () => {
const ctx = makeCtx({ doc }); const ctx = makeCtx({ doc });
const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!; const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!;
// E1: Drag-Interaktion (down + up statt nur down)
row.handlers.down!(pe(0, 0), ctx); row.handlers.down!(pe(0, 0), ctx);
row.handlers.up!(pe(200, 0), ctx); // Länge 200, spacing 50 → 4 Stühle
const all = doc.getAllElements(); const all = doc.getAllElements();
expect(all.length).toBeGreaterThanOrEqual(2); expect(all.length).toBeGreaterThanOrEqual(2);
const rowIds = new Set(all.map((e) => e.properties.rowId)); const rowIds = new Set(all.map((e) => e.properties.rowId));
@@ -922,3 +926,67 @@ describe('C3fin interaktiv: handle-drag on select tool', () => {
expect(selIds).toEqual(['r3']); expect(selIds).toEqual(['r3']);
}); });
}); });
describe('E1: seating-row drag', () => {
it('berechnet count aus Laenge und committet in EINER Transaktion', () => {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
const ctx = makeCtx({ doc, options: { spacing: 50 } });
const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!;
// Start bei (0,0), Ende bei (300,0): Länge 300, spacing 50 → 6 Stühle
row.handlers.down!(pe(0, 0), ctx);
row.handlers.move!(pe(150, 0), ctx); // Live-Preview
expect(ctx.previews.length).toBe(1);
row.handlers.up!(pe(300, 0), ctx);
const all = doc.getAllElements();
expect(all.length).toBe(6);
expect(all.every((e) => e.type === 'chair')).toBe(true);
// Eine rowId für alle:
const rowIds = new Set(all.map((e) => e.properties.rowId));
expect(rowIds.size).toBe(1);
// EIN Undo entfernt die ganze Reihe:
expect(doc.undo()).toBe(true);
expect(doc.getAllElements().length).toBe(0);
});
it('cancel verwirft die laufende Reihe', () => {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
const ctx = makeCtx({ doc });
const row = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-row')!;
row.handlers.down!(pe(0, 0), ctx);
row.handlers.cancel!(ctx);
row.handlers.up!(pe(100, 0), ctx); // nach cancel: kein dragStart
expect(doc.getAllElements().length).toBe(0);
});
});
describe('E1: seating-block drag', () => {
it('berechnet rows×cols aus Distanz und committet Multi-Element-Set', () => {
const { ydoc } = createInMemoryDoc();
const doc = new CADDocument(ydoc);
const ctx = makeCtx({ doc, options: { colSpacing: 50, rowSpacing: 50 } });
const block = eventToolsV2Plugin.tools!.find((t) => t.manifest.id === 'seating-block')!;
// Start (0,0), Ende (150,100): w=150/50=3 cols, h=100/50=2 rows → 6 Stühle
block.handlers.down!(pe(0, 0), ctx);
block.handlers.up!(pe(150, 100), ctx);
const all = doc.getAllElements();
expect(all.length).toBe(6);
expect(all.every((e) => e.type === 'chair')).toBe(true);
// BUG-1-Regressionsschutz: rowIds pro Reihe eindeutig
const rowIds = new Set(all.map((e) => e.properties.rowId));
expect(rowIds.size).toBe(2);
// EIN Undo entfernt den ganzen Block:
expect(doc.undo()).toBe(true);
expect(doc.getAllElements().length).toBe(0);
});
});