feat(schematic): Wire-Dragging, Farbpropagierung, Busbar-Typen

- Wire-Segment-Dragging: Shift+Klick/Mittlere Maustaste zum Verschieben
  - Horizontale Segmente nur vertikal, vertikale nur horizontal
  - Grid-Snapping, Live-Vorschau, Start/End-Segmente fixiert
- PWA: Automatische Farbpropagierung bei Einspeisung (L1/L2/L3/N/PE)
  - N-Phase als Input hinzugefuegt
  - propagateInputColor() aktualisiert Abgaenge
- Busbar-Typen aus Datenbank statt hardcodiert
- ChangeLog aktualisiert

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-04 15:46:46 +01:00
parent 95e1860940
commit 848232c5a6
4 changed files with 410 additions and 4 deletions

View file

@ -14,6 +14,30 @@
- Leitungen "verschwinden" hinter Bloecken und kommen auf der anderen Seite wieder raus
- Professionelleres Erscheinungsbild wie in echten Schaltplan-Editoren
- **Wire-Segment-Dragging**: Leitungen koennen verschoben werden ohne Verbindungen zu verlieren
- Shift+Klick oder Mittlere Maustaste auf Leitungssegment zum Ziehen
- Horizontale Segmente nur vertikal verschiebbar, vertikale nur horizontal
- Start- und End-Segmente (an Terminals) bleiben fix
- Automatisches Grid-Snapping (25px)
- Live-Vorschau waehrend dem Ziehen
- Neue Funktionen: `parsePathToPoints()`, `pointsToPath()`, `findClickedSegment()`
- `startWireDrag()`, `handleWireDragMove()`, `finishWireDrag()`, `cancelWireDrag()`
- **Busbar-Typen aus Datenbank**: Phasenschienen-Typen dynamisch aus DB laden
- Edit-Dialog nutzt jetzt `busbarTypes` Array statt hardcodierter Optionen
- `fk_busbar_type` wird beim Update korrekt gespeichert
- Admin-Seite fuer Busbar-Typen mit phases_config JSON-Feld
- **PWA: Farbpropagierung bei Einspeisung**: Automatische Farbuebernahme
- Bei Auswahl einer Input-Phase (L1/L2/L3/N/PE) wird Farbe automatisch gesetzt
- Funktion `propagateInputColor()` aktualisiert Farben auf Abgaengen
- Phase-Matching: L1 matched L1, LN, L1N; N matched N; etc.
- Funktioniert online und offline (mit Queue)
- **PWA: N-Phase als Einspeisung**: Neutralleiter jetzt als Input-Phase waehlbar
- INPUT_PHASES erweitert um 'N': ['L1', 'L2', 'L3', 'N', 'PE']
- 3P und 3P+N entfernt (nur Einzel-Phasen)
### Verbesserungen
- **Zeichenmodus-Verhalten**: Konsistentes Verhalten im manuellen Zeichenmodus

View file

@ -5222,6 +5222,14 @@
wireExtendExistingPoints: [], // Existing path points of connection being extended
WIRE_GRID_SIZE: 25, // Snap grid size in pixels (larger = fewer points)
// Wire segment dragging
wireDragMode: false, // Currently dragging a wire segment
wireDragConnId: null, // Connection ID being dragged
wireDragSegmentIndex: null, // Index of segment being dragged (0-based)
wireDragIsHorizontal: null, // true = horizontal segment (drag Y), false = vertical (drag X)
wireDragStartPos: null, // Starting mouse position
wireDragOriginalPoints: [], // Original path points before drag
// Display settings (persisted in localStorage)
displaySettings: {
phaseColors: true, // Color wires by phase (L1=brown, L2=black, L3=gray, N=blue, PE=green)
@ -8031,6 +8039,44 @@
this.addEventListener('mouseleave', function() {
$visiblePath.attr('stroke-width', wireWidth);
});
// Wire segment dragging - mousedown to start drag
this.addEventListener('mousedown', function(e) {
// Only with middle mouse button or Shift+left click
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
// Don't start drag if in wire draw mode
if (self.wireDrawMode) return;
var $svg = $(e.target).closest('svg');
var svgRect = $svg[0].getBoundingClientRect();
var clickX = e.clientX - svgRect.left;
var clickY = e.clientY - svgRect.top;
if (self.startWireDrag(connId, clickX, clickY)) {
// Successfully started drag - bind move/up handlers
var moveHandler = function(moveE) {
var mx = moveE.clientX - svgRect.left;
var my = moveE.clientY - svgRect.top;
self.handleWireDragMove(mx, my);
};
var upHandler = function(upE) {
var ux = upE.clientX - svgRect.left;
var uy = upE.clientY - svgRect.top;
self.finishWireDrag(ux, uy);
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
};
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
}
});
this.style.cursor = 'pointer';
});
@ -8193,6 +8239,37 @@
this.addEventListener('mouseleave', function() {
$visiblePath.attr('stroke-width', wireWidth);
});
// Wire segment dragging
this.addEventListener('mousedown', function(e) {
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
if (self.wireDrawMode) return;
var $svg = $(e.target).closest('svg');
var svgRect = $svg[0].getBoundingClientRect();
var clickX = e.clientX - svgRect.left;
var clickY = e.clientY - svgRect.top;
if (self.startWireDrag(connId, clickX, clickY)) {
var moveHandler = function(moveE) {
var mx = moveE.clientX - svgRect.left;
var my = moveE.clientY - svgRect.top;
self.handleWireDragMove(mx, my);
};
var upHandler = function(upE) {
var ux = upE.clientX - svgRect.left;
var uy = upE.clientY - svgRect.top;
self.finishWireDrag(ux, uy);
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
};
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
}
});
this.style.cursor = 'pointer';
});
},
@ -10802,6 +10879,226 @@
}
},
// ========================================
// Wire Segment Dragging
// ========================================
/**
* Parse SVG path data to array of points
*/
parsePathToPoints: function(pathData) {
if (!pathData) return [];
var points = [];
var regex = /([ML])\s*([\d.]+)\s+([\d.]+)/g;
var match;
while ((match = regex.exec(pathData)) !== null) {
points.push({
x: parseFloat(match[2]),
y: parseFloat(match[3])
});
}
return points;
},
/**
* Convert points array back to SVG path string
*/
pointsToPath: function(points) {
if (!points || points.length === 0) return '';
var path = '';
for (var i = 0; i < points.length; i++) {
path += (i === 0 ? 'M ' : 'L ') + Math.round(points[i].x) + ' ' + Math.round(points[i].y) + ' ';
}
return path.trim();
},
/**
* Find which segment of a path was clicked
* Returns: { index: segmentIndex, isHorizontal: bool } or null
*/
findClickedSegment: function(points, clickX, clickY, threshold) {
threshold = threshold || 15;
for (var i = 0; i < points.length - 1; i++) {
var p1 = points[i];
var p2 = points[i + 1];
// Check if click is near this segment
var isHorizontal = Math.abs(p1.y - p2.y) < 2; // Horizontal if Y values are same
var isVertical = Math.abs(p1.x - p2.x) < 2; // Vertical if X values are same
if (isHorizontal) {
// Horizontal segment - check if click is within X range and near Y
var minX = Math.min(p1.x, p2.x);
var maxX = Math.max(p1.x, p2.x);
if (clickX >= minX - threshold && clickX <= maxX + threshold &&
Math.abs(clickY - p1.y) <= threshold) {
return { index: i, isHorizontal: true };
}
} else if (isVertical) {
// Vertical segment - check if click is within Y range and near X
var minY = Math.min(p1.y, p2.y);
var maxY = Math.max(p1.y, p2.y);
if (clickY >= minY - threshold && clickY <= maxY + threshold &&
Math.abs(clickX - p1.x) <= threshold) {
return { index: i, isHorizontal: false };
}
}
}
return null;
},
/**
* Start dragging a wire segment
*/
startWireDrag: function(connId, clickX, clickY) {
var conn = this.connections.find(function(c) { return c.id == connId; });
if (!conn || !conn.path_data) return false;
var points = this.parsePathToPoints(conn.path_data);
if (points.length < 3) return false; // Need at least 3 points to have a draggable segment
var segment = this.findClickedSegment(points, clickX, clickY);
if (!segment) return false;
// Don't allow dragging the first or last segment (connected to terminals)
if (segment.index === 0 || segment.index === points.length - 2) {
this.showMessage('Start- und End-Segmente können nicht verschoben werden', 'warning');
return false;
}
this.wireDragMode = true;
this.wireDragConnId = connId;
this.wireDragSegmentIndex = segment.index;
this.wireDragIsHorizontal = segment.isHorizontal;
this.wireDragStartPos = { x: clickX, y: clickY };
this.wireDragOriginalPoints = JSON.parse(JSON.stringify(points));
// Visual feedback
$(this.svgElement).css('cursor', segment.isHorizontal ? 'ns-resize' : 'ew-resize');
this.log('Wire drag started - segment:', segment.index, 'horizontal:', segment.isHorizontal);
return true;
},
/**
* Handle mouse move during wire drag
*/
handleWireDragMove: function(mouseX, mouseY) {
if (!this.wireDragMode) return;
var points = JSON.parse(JSON.stringify(this.wireDragOriginalPoints));
var segIdx = this.wireDragSegmentIndex;
if (this.wireDragIsHorizontal) {
// Horizontal segment - move Y (vertical drag)
var deltaY = mouseY - this.wireDragStartPos.y;
var newY = points[segIdx].y + deltaY;
// Snap to grid
newY = Math.round(newY / this.WIRE_GRID_SIZE) * this.WIRE_GRID_SIZE;
// Update both points of this segment
points[segIdx].y = newY;
points[segIdx + 1].y = newY;
} else {
// Vertical segment - move X (horizontal drag)
var deltaX = mouseX - this.wireDragStartPos.x;
var newX = points[segIdx].x + deltaX;
// Snap to grid
newX = Math.round(newX / this.WIRE_GRID_SIZE) * this.WIRE_GRID_SIZE;
// Update both points of this segment
points[segIdx].x = newX;
points[segIdx + 1].x = newX;
}
// Update the visual path in SVG
var newPath = this.pointsToPath(points);
var $conn = $(this.svgElement).find('.schematic-connection[data-connection-id="' + this.wireDragConnId + '"]');
var $hitarea = $(this.svgElement).find('.schematic-connection-hitarea[data-connection-id="' + this.wireDragConnId + '"]');
var $shadow = $conn.closest('.schematic-connection-group').find('.schematic-connection-shadow');
$conn.attr('d', newPath);
$hitarea.attr('d', newPath);
$shadow.attr('d', newPath);
},
/**
* Finish wire drag and save
*/
finishWireDrag: function(mouseX, mouseY) {
if (!this.wireDragMode) return;
var points = JSON.parse(JSON.stringify(this.wireDragOriginalPoints));
var segIdx = this.wireDragSegmentIndex;
if (this.wireDragIsHorizontal) {
var deltaY = mouseY - this.wireDragStartPos.y;
var newY = points[segIdx].y + deltaY;
newY = Math.round(newY / this.WIRE_GRID_SIZE) * this.WIRE_GRID_SIZE;
points[segIdx].y = newY;
points[segIdx + 1].y = newY;
} else {
var deltaX = mouseX - this.wireDragStartPos.x;
var newX = points[segIdx].x + deltaX;
newX = Math.round(newX / this.WIRE_GRID_SIZE) * this.WIRE_GRID_SIZE;
points[segIdx].x = newX;
points[segIdx + 1].x = newX;
}
// Clean up collinear points
points = this.cleanupPathPoints(points);
var newPath = this.pointsToPath(points);
var self = this;
// Save to server
$.ajax({
url: baseUrl + '/custom/kundenkarte/ajax/equipment_connection.php',
method: 'POST',
data: {
action: 'update',
connection_id: this.wireDragConnId,
path_data: newPath,
token: $('input[name="token"]').val()
},
success: function(response) {
if (response.success) {
// Update local data
var conn = self.connections.find(function(c) { return c.id == self.wireDragConnId; });
if (conn) {
conn.path_data = newPath;
}
self.showMessage('Leitung verschoben', 'success');
} else {
self.showMessage('Fehler: ' + (response.error || 'Unbekannt'), 'error');
self.renderConnections(); // Revert visual
}
self.cancelWireDrag();
},
error: function() {
self.showMessage('Netzwerkfehler', 'error');
self.renderConnections(); // Revert visual
self.cancelWireDrag();
}
});
},
/**
* Cancel wire drag
*/
cancelWireDrag: function() {
this.wireDragMode = false;
this.wireDragConnId = null;
this.wireDragSegmentIndex = null;
this.wireDragIsHorizontal = null;
this.wireDragStartPos = null;
this.wireDragOriginalPoints = [];
$(this.svgElement).css('cursor', '');
},
// Update an existing connection with extended path
updateExtendedConnection: function(connId, newTargetEqId, newTargetTermId, newPathData) {
var self = this;

View file

@ -226,6 +226,17 @@
// Medium-Type Change -> Spezifikationen laden
$('#conn-medium-type').on('change', handleMediumTypeChange);
// Connection-Type Change -> Auto-Farbe bei Input-Phasen
$('#conn-type').on('change', function() {
if (App.connectionDirection === 'input') {
const phase = $(this).val();
if (phase) {
const color = getPhaseColor(phase);
$('#conn-color').val(color);
}
}
});
// Bestätigungsdialog
$('#btn-confirm-ok').on('click', function() {
closeModal('confirm');
@ -2790,7 +2801,13 @@
const response = await apiCall('ajax/pwa_api.php', data);
if (response.success) {
const list = App.connectionDirection === 'input' ? App.inputs : App.outputs;
updateLocal(list.find(c => c.id == App.editConnectionId));
const conn = list.find(c => c.id == App.editConnectionId);
updateLocal(conn);
// Farbpropagierung bei Input
if (App.connectionDirection === 'input' && connectionType) {
const eqId = conn ? conn.fk_target : null;
await propagateInputColor(eqId, connectionType, color);
}
renderEditor();
showToast('Verbindung aktualisiert', 'success');
} else {
@ -2803,7 +2820,13 @@
} else {
queueOfflineAction(data);
const list = App.connectionDirection === 'input' ? App.inputs : App.outputs;
updateLocal(list.find(c => c.id == App.editConnectionId));
const conn = list.find(c => c.id == App.editConnectionId);
updateLocal(conn);
// Farbpropagierung bei Input (offline)
if (App.connectionDirection === 'input' && connectionType) {
const eqId = conn ? conn.fk_target : null;
propagateInputColor(eqId, connectionType, color);
}
renderEditor();
showToast('Wird synchronisiert...', 'warning');
}
@ -2846,6 +2869,10 @@
if (App.connectionDirection === 'input') {
newConn.fk_target = App.connectionEquipmentId;
App.inputs.push(newConn);
// Farbpropagierung bei neuem Input
if (connectionType) {
await propagateInputColor(App.connectionEquipmentId, connectionType, color);
}
} else {
newConn.fk_source = App.connectionEquipmentId;
App.outputs.push(newConn);
@ -2865,6 +2892,10 @@
if (App.connectionDirection === 'input') {
newConn.fk_target = App.connectionEquipmentId;
App.inputs.push(newConn);
// Farbpropagierung bei neuem Input (offline)
if (connectionType) {
propagateInputColor(App.connectionEquipmentId, connectionType, color);
}
} else {
newConn.fk_source = App.connectionEquipmentId;
App.outputs.push(newConn);
@ -2877,6 +2908,60 @@
App.editConnectionId = null;
}
/**
* Farbpropagierung: Wenn Input-Phase gesetzt, Farbe auf Outputs übertragen
* @param {number} equipmentId - Equipment-ID
* @param {string} phase - Phase (L1, L2, L3, N, PE)
* @param {string} color - Farbe der Einspeisung
*/
async function propagateInputColor(equipmentId, phase, color) {
if (!equipmentId || !phase || !color) return;
// Finde alle Outputs dieses Equipment
const outputs = App.outputs.filter(o => o.fk_source == equipmentId);
if (outputs.length === 0) return;
// Update Outputs mit passender Phase
for (const output of outputs) {
// Phase-Matching: L1 -> L1, LN, L1N; L2 -> L2; etc.
const outputType = output.connection_type || '';
let matches = false;
if (phase === 'L1' && (outputType.includes('L1') || outputType === 'LN')) matches = true;
else if (phase === 'L2' && outputType.includes('L2')) matches = true;
else if (phase === 'L3' && outputType.includes('L3')) matches = true;
else if (phase === 'N' && outputType.includes('N')) matches = true;
else if (phase === 'PE' && outputType.includes('PE')) matches = true;
if (matches && output.color !== color) {
output.color = color;
// Backend aktualisieren
if (App.isOnline) {
try {
await apiCall('ajax/pwa_api.php', {
action: 'update_connection',
connection_id: output.id,
color: color
});
} catch (err) {
queueOfflineAction({
action: 'update_connection',
connection_id: output.id,
color: color
});
}
} else {
queueOfflineAction({
action: 'update_connection',
connection_id: output.id,
color: color
});
}
}
}
}
/**
* Connection löschen (mit Bestätigung)
*/

4
sw.js
View file

@ -3,8 +3,8 @@
* Offline-First für Schaltschrank-Dokumentation
*/
const CACHE_NAME = 'kundenkarte-pwa-v11.6';
const OFFLINE_CACHE = 'kundenkarte-offline-v11.6';
const CACHE_NAME = 'kundenkarte-pwa-v11.7';
const OFFLINE_CACHE = 'kundenkarte-offline-v11.7';
// Statische Assets die immer gecached werden (ohne Query-String)
const STATIC_ASSETS = [