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}`);