From 6a333f198decb02bebe3551efeec4c08a6da8564 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 30 Oct 2025 15:24:57 +0100 Subject: [PATCH] Enhance CourtDrawing components with new rendering logic and overlay features Updated CourtDrawingRender.vue to include an overlay displaying the rendered code string and raw values for debugging. Refactored position resolution logic to streamline the determination of start positions. Added methods for building and applying render codes, improving the overall functionality of the drawing tool. Enhanced CourtDrawingTool.vue with updated button styles and layout adjustments for better user experience. Updated DiaryView.vue to ensure proper handling of drawing data and start positions from render codes. --- .../src/components/CourtDrawingRender.vue | 122 ++++++++++- frontend/src/components/CourtDrawingTool.vue | 202 ++++++++++++++---- frontend/src/views/DiaryView.vue | 33 ++- 3 files changed, 302 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/CourtDrawingRender.vue b/frontend/src/components/CourtDrawingRender.vue index c8a6b67..0f392b5 100644 --- a/frontend/src/components/CourtDrawingRender.vue +++ b/frontend/src/components/CourtDrawingRender.vue @@ -133,6 +133,14 @@ export default { this.drawTable(); this.drawStartCircles(); this.drawArrowsAndLabels(); + // Zuletzt: Overlay mit dem gerenderten Kürzel-String + const code = this.buildCodeString(); + if (code) { + this.drawOverlayText(code); + } + // Debug: Rohwerte anzeigen + const raw = `raw: start=${this.drawingData?.selectedStartPosition || '-'} circle=${this.drawingData?.selectedCirclePosition || '-'}`; + this.drawOverlayText(raw, 2); }, drawTable() { const ctx = this.ctx; @@ -221,9 +229,7 @@ export default { const botY = tableY + tableHeight - cfg.bottomYOffset; // Mapping und Fallback: bei "AS" auf 'middle' - const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' }; - const selKey = this.drawingData?.selectedStartPosition || 'AS'; - const selectedPos = map[selKey] || 'middle'; + const selectedPos = this.resolveStartPos(); const y = selectedPos === 'top' ? topY : selectedPos === 'bottom' ? botY : midY; ctx.fillStyle = cfg.selectedColor; @@ -269,8 +275,7 @@ export default { const tableX = (this.config.canvas.width - tblW) / 2; const tableY = (this.config.canvas.height - tblH) / 2; const circleX = tableX - sc.x; // Kreis links vor dem Tisch - const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' }; - const pos = map[this.drawingData?.selectedStartPosition] || 'middle'; + const pos = this.resolveStartPos(); const y = pos === 'top' ? tableY + sc.topYOffset : pos === 'bottom' ? tableY + tblH - sc.bottomYOffset : tableY + tblH / 2; const isVH = (this.drawingData?.strokeType || 'VH') === 'VH'; const startX = isVH @@ -373,6 +378,97 @@ export default { } } }, + applyRenderCode(code) { + try { + const startMatch = code.match(/ASv([LMR])/); + if (startMatch) { + const map = { L: 'AS1', M: 'AS2', R: 'AS3' }; + this.drawingData.selectedStartPosition = map[startMatch[1]] || 'AS2'; + this.drawingData.selectedCirclePosition = this.drawingData.selectedStartPosition === 'AS1' ? 'top' : this.drawingData.selectedStartPosition === 'AS3' ? 'bottom' : 'middle'; + } + // Hauptziel extrahieren (nach dem ersten → ) + const arrowParts = code.split('→').map(s => s.trim()); + if (arrowParts.length >= 2) { + const mainLabel = arrowParts[1].split('/')[0].trim(); + const mainNum = this.labelToNumber(mainLabel); + if (mainNum) { + this.drawingData.targetPosition = String(mainNum); + } + } + // Zusatzschläge: Segmente nach '/' + const extras = []; + if (code.includes('/')) { + const extraSegments = code.split('/').slice(1); + extraSegments.forEach(seg => { + const m = seg.split('→')[1]; + if (m) { + const lbl = m.trim(); + const num = this.labelToNumber(lbl); + if (num) { + extras.push({ targetPosition: String(num) }); + } + } + }); + } + this.drawingData.additionalStrokes = extras; + } catch (e) { + // still draw with whatever is present + } + }, + labelToNumber(label) { + const map = { + 'VH L': 1, 'M L': 2, 'RH L': 3, + 'VH H': 4, 'M H': 5, 'RH H': 6, + 'VH K': 7, 'M K': 8, 'RH K': 9 + }; + return map[label] || null; + }, + buildCodeString() { + if (!this.drawingData) return ''; + // Wenn ein expliziter renderCode mitgeliefert wird, verwende den + if (this.drawingData && (this.drawingData.code || this.drawingData.renderCode)) { + return this.drawingData.code || this.drawingData.renderCode; + } + // sonst: Nutze die gleiche Logik wie beim Zeichnen (resolveStartPos) + const pos = this.resolveStartPos(); // 'top' | 'middle' | 'bottom' + const start = pos === 'top' ? 'vL' : pos === 'bottom' ? 'vR' : 'vM'; + const strokeSide = this.drawingData.strokeType || ''; + const spin = this.abbrevSpin(this.drawingData.spinType); + const mainTargetMap = { + '1': 'VH L', '2': 'M L', '3': 'RH L', + '4': 'VH H', '5': 'M H', '6': 'RH H', + '7': 'VH K', '8': 'M K', '9': 'RH K' + }; + let code = `AS${start} ${strokeSide} ${spin}`.trim(); + if (this.drawingData.targetPosition) { + const main = mainTargetMap[String(this.drawingData.targetPosition)] || this.drawingData.targetPosition; + code += ` → ${main}`; + } + const extras = Array.isArray(this.drawingData.additionalStrokes) ? this.drawingData.additionalStrokes : []; + if (extras.length) { + extras.forEach(stroke => { + const tgt = mainTargetMap[String(stroke.targetPosition)] || stroke.targetPosition; + code += ` / ${stroke.side} ${stroke.type} → ${tgt}`; + }); + } + return code; + }, + drawOverlayText(text, line = 1) { + const ctx = this.ctx; + const padding = 6; + const x = 10; + const y = this.config.canvas.height - 16 - (line - 1) * 22; + ctx.save(); + ctx.font = '12px Arial'; + const width = ctx.measureText(text).width; + const boxW = width + padding * 2; + const boxH = 16 + padding * 2; + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fillRect(x - padding, y - 12 - padding, boxW, boxH); + ctx.fillStyle = '#ffffff'; + ctx.fillText(text, x, y); + ctx.restore(); + }, getStartCircleCenter() { const cfg = this.config.startCircles; const tableWidth = this.config.table.width; @@ -380,15 +476,25 @@ export default { const tableX = (this.config.canvas.width - tableWidth) / 2; const tableY = (this.config.canvas.height - tableHeight) / 2; const circleX = tableX - cfg.x; - const map = { AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle' }; - const selKey = this.drawingData?.selectedStartPosition || 'AS'; - const selectedPos = map[selKey] || 'middle'; + const selectedPos = this.resolveStartPos(); const topY = tableY + cfg.topYOffset; const midY = tableY + tableHeight / 2; const botY = tableY + tableHeight - cfg.bottomYOffset; const y = selectedPos === 'top' ? topY : selectedPos === 'bottom' ? botY : midY; return { x: circleX, y }; }, + resolveStartPos() { + // 1) bevorzugt AS1/AS2/AS3 aus drawingData.selectedStartPosition + const key = this.drawingData?.selectedStartPosition; + if (key === 'AS1') return 'top'; + if (key === 'AS2') return 'middle'; + if (key === 'AS3') return 'bottom'; + // 2) Fallback über selectedCirclePosition (top/middle/bottom) + const circlePos = this.drawingData?.selectedCirclePosition; + if (circlePos === 'top' || circlePos === 'middle' || circlePos === 'bottom') return circlePos; + // 3) Default Mitte + return 'middle'; + }, drawHitMarker(ctx, pos) { if (!pos) return; const mk = this.config.hitMarker; diff --git a/frontend/src/components/CourtDrawingTool.vue b/frontend/src/components/CourtDrawingTool.vue index 62ad2d1..0c5d417 100644 --- a/frontend/src/components/CourtDrawingTool.vue +++ b/frontend/src/components/CourtDrawingTool.vue @@ -22,7 +22,8 @@
- Aufschlag: +
+ Aufschlag:
+
+ Schnitt:
-
- Zielposition: -
+ Zielposition: +
+
@@ -111,6 +114,7 @@
+ Seite:
- -
- Zielposition: -
- -
-
-
+ Schlagart: + + + +
+ +
+ Zielposition: +
+ +
@@ -363,12 +393,21 @@ export default { this.emitDrawingData(); this.updateTextFields(); }, + selectedCirclePosition() { + // bei Wechsel der Kreisposition: Daten + Strings aktualisieren + this.emitDrawingData(); + this.updateTextFields(); + if (this.canvas && this.ctx) { + this.drawCourt(); + } + }, selectedStartPosition() { // Neu zeichnen wenn sich die Auswahl ändert if (this.canvas && this.ctx) { this.drawCourt(); } this.emitDrawingData(); + this.updateTextFields(); }, drawingData: { handler(newVal, oldVal) { @@ -632,19 +671,17 @@ export default { const circleRadius = config.startCircles.radius; const circleX = config.startCircles.x; let startY; - - switch(this.selectedCirclePosition) { - case 'top': - startY = tableY + config.startCircles.topYOffset; - break; - case 'middle': - startY = tableY + tableHeight / 2; - break; - case 'bottom': - startY = tableY + tableHeight - config.startCircles.bottomYOffset; - break; - default: - return; + // Bestimme Startkreis primär aus selectedStartPosition (AS1/AS2/AS3), + // fallback auf selectedCirclePosition + const circlePos = this.resolveStartCirclePosition(); + if (circlePos === 'top') { + startY = tableY + config.startCircles.topYOffset; + } else if (circlePos === 'middle') { + startY = tableY + tableHeight / 2; + } else if (circlePos === 'bottom') { + startY = tableY + tableHeight - config.startCircles.bottomYOffset; + } else { + return; } // Zielposition-Kreis Koordinaten ermitteln @@ -734,6 +771,15 @@ export default { ); ctx.stroke(); }, + + // Leitet die visuelle Kreis-Position aus selectedStartPosition ab + resolveStartCirclePosition() { + if (this.selectedStartPosition === 'AS1') return 'top'; + if (this.selectedStartPosition === 'AS2') return 'middle'; + if (this.selectedStartPosition === 'AS3') return 'bottom'; + // Fallback: benutze bestehenden Kreisstatus + return this.selectedCirclePosition; + }, drawArrowToLeftTarget(ctx, tableX, tableY, tableWidth, tableHeight) { const config = this.config; @@ -1034,7 +1080,7 @@ export default { return; } // Andernfalls: wenn wir einen Zusatzschlag auf die rechte Seite wählen sollen - if (this.targetPosition && this.additionalStrokes.length < 3 && this.getNextAdditionalSide() === 'right') { + if (this.targetPosition && this.additionalStrokes.length < 4 && this.getNextAdditionalSide() === 'right') { this.nextStrokeTargetPosition = clickedTarget; this.drawCourt(); this.emitDrawingData(); @@ -1243,6 +1289,7 @@ export default { nextStrokeTargetPosition: this.nextStrokeTargetPosition, exerciseCounter: this.exerciseCounter, additionalStrokes: this.additionalStrokes, + code: this.getFullCode ? this.getFullCode() : '', timestamp: new Date().toISOString() }; @@ -1432,7 +1479,8 @@ export default { if (this.additionalStrokes.length > 0) { this.additionalStrokes.forEach(stroke => { const strokeNameMap = { - 'US': 'Schupf', 'OS': 'Konter', 'TS': 'Topspin', 'FL': 'Flip', 'BL': 'Block' + 'US': 'Schupf', 'OS': 'Konter', 'TS': 'Topspin', 'F': 'Flip', 'B': 'Block', + 'SCH': 'Schuss', 'SAB': 'Schnittabwehr', 'BAB': 'Ballonabwehr' }; const sideNameMap = { 'VH': 'Vorhand', 'RH': 'Rückhand' @@ -1454,6 +1502,7 @@ export default { addNextStroke() { if (!this.nextStrokeTargetPosition) return; + if (this.additionalStrokes.length >= 4) return; // Neuen Schlag zur Liste hinzufügen this.additionalStrokes.push({ @@ -1493,7 +1542,8 @@ export default { , emitDrawingData() { const drawingData = { - selectedStartPosition: this.selectedStartPosition, + selectedStartPosition: this.getEffectiveStartPosition(), + selectedCirclePosition: this.resolveStartCirclePosition(), strokeType: this.strokeType, spinType: this.spinType, targetPosition: this.targetPosition, @@ -1501,9 +1551,20 @@ export default { nextStrokeSide: this.nextStrokeSide, nextStrokeTargetPosition: this.nextStrokeTargetPosition, exerciseCounter: this.exerciseCounter, - additionalStrokes: this.additionalStrokes + additionalStrokes: this.additionalStrokes, + code: this.getFullCode ? this.getFullCode() : '' }; this.$emit('update-drawing-data', drawingData); + }, + + getEffectiveStartPosition() { + if (this.selectedStartPosition === 'AS1' || this.selectedStartPosition === 'AS2' || this.selectedStartPosition === 'AS3') { + return this.selectedStartPosition; + } + if (this.selectedCirclePosition === 'top') return 'AS1'; + if (this.selectedCirclePosition === 'middle') return 'AS2'; + if (this.selectedCirclePosition === 'bottom') return 'AS3'; + return 'AS2'; } } } @@ -1630,6 +1691,14 @@ canvas { opacity: 0.8; } +.group-label { + display: block; + margin: 0 0 4px 0; + font-size: 12px; + font-weight: 600; + color: #495057; +} + input[type="color"] { width: 40px; height: 30px; @@ -1710,13 +1779,11 @@ input[type="range"] { } .next-stroke-type { - display: flex; gap: 0.5rem; flex-wrap: nowrap; } .next-stroke-buttons { - display: flex; gap: 0.5rem; flex-wrap: nowrap; } @@ -1732,4 +1799,47 @@ input[type="range"] { margin: 0.25rem 0; font-size: 0.9rem; } + +/* Kompakte 3x3 Zielauswahl */ +.target-grid { + display: grid; + grid-template-columns: repeat(3, 24px); + grid-auto-rows: 24px; + gap: 0; +} + +.grid-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + margin: 0; + border: none; + border-radius: 2px; + background: #28a745; /* grün, auch wenn nichts ausgewählt ist */ + color: #ffffff; + font-size: 12px; + font-weight: 500; + line-height: 1; + cursor: pointer; +} + +.grid-btn.is-active { + color: #FFD700; /* gelb */ + font-weight: 700; /* fett */ +} + +/* Ausgewählte Aufschlag-/Seiten-Buttons: fette gelbe Schrift */ +.btn-stroke.btn-primary { + color: #FFD700 !important; + font-weight: 700; +} + +/* Ausgewählte Schlagtyp-Buttons: fette gelbe Schrift */ +.btn-stroke-type.btn-primary { + color: #FFD700 !important; + font-weight: 700; +} diff --git a/frontend/src/views/DiaryView.vue b/frontend/src/views/DiaryView.vue index 5eed02c..dd199f4 100644 --- a/frontend/src/views/DiaryView.vue +++ b/frontend/src/views/DiaryView.vue @@ -663,11 +663,20 @@ export default { if (!pa) return null; if (pa.drawingData && typeof pa.drawingData === 'object') { console.debug('DiaryView: drawingData (object) gefunden für', pa.id); - return pa.drawingData; + const data = { ...pa.drawingData }; + // Kürzel aus PA anreichern + if (pa.code && !data.code) data.code = pa.code; + if (pa.renderCode && !data.code) data.code = pa.renderCode; + // Falls Startposition nicht gesetzt (oder nur "AS"), aus renderCode ableiten + this._ensureStartPositionFromRenderCode(pa, data); + return data; } if (pa.drawingData && typeof pa.drawingData === 'string') { const parsed = JSON.parse(pa.drawingData); console.debug('DiaryView: drawingData (string→parsed) für', pa.id, parsed); + if (pa.code && !parsed.code) parsed.code = pa.code; + if (pa.renderCode && !parsed.code) parsed.code = pa.renderCode; + this._ensureStartPositionFromRenderCode(pa, parsed); return parsed; } // Fallback: falls über images[0].drawingData geliefert wurde @@ -679,6 +688,9 @@ export default { ? JSON.parse(withData.drawingData) : withData.drawingData; console.debug('DiaryView: drawingData aus images für', pa.id, 'imageId=', withData.id, parsedImg); + if (pa.code && !parsedImg.code) parsedImg.code = pa.code; + if (pa.renderCode && !parsedImg.code) parsedImg.code = pa.renderCode; + this._ensureStartPositionFromRenderCode(pa, parsedImg); return parsedImg; } } @@ -687,6 +699,25 @@ export default { } return null; }, + _ensureStartPositionFromRenderCode(pa, data) { + try { + if (!data) return; + // Immer renderCode mitgeben, wenn vorhanden + if (pa && pa.renderCode) { + data.renderCode = pa.renderCode; + } + const hasExplicit = data.selectedStartPosition === 'AS1' || data.selectedStartPosition === 'AS2' || data.selectedStartPosition === 'AS3'; + if (hasExplicit) return; + const code = pa && (pa.code || pa.renderCode) ? String(pa.code || pa.renderCode) : ''; + if (!code) return; + // Suche nach ASvL/ASvM/ASvR + const m = code.match(/ASv([LMR])/); + if (m) { + const map = { L: 'AS1', M: 'AS2', R: 'AS3' }; + data.selectedStartPosition = map[m[1]] || 'AS2'; + } + } catch (e) { } + }, async init() { if (this.isAuthenticated && this.currentClub) { const response = await apiClient.get(`/diary/${this.currentClub}`);