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.
This commit is contained in:
Torsten Schulz (local)
2025-10-30 15:24:57 +01:00
parent 7ea719c178
commit 6a333f198d
3 changed files with 302 additions and 55 deletions

View File

@@ -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;

View File

@@ -22,7 +22,8 @@
<div class="selection-group">
<!-- Schlagart Auswahl -->
<div class="stroke-selection">
<span>Aufschlag:</span>
<div>
<span class="group-label">Aufschlag:</span>
<div class="stroke-buttons">
<button
type="button"
@@ -41,9 +42,11 @@
RH
</button>
</div>
</div>
<!-- Schnittoption Auswahl -->
<div class="spin-selection">
<span class="group-label">Schnitt:</span>
<div class="spin-buttons">
<button
type="button"
@@ -87,23 +90,23 @@
</button>
</div>
</div>
</div>
<!-- Zielposition für Hauptschlag: explizite Auswahl 19 als Buttons -->
<div class="target-selection" v-if="spinType">
<span>Zielposition:</span>
<div class="target-buttons">
<span class="group-label">Zielposition:</span>
<div class="target-grid">
<button
v-for="n in 9"
:key="`main-target-${n}`"
type="button"
:class="['btn-small', { 'btn-primary': targetPosition === String(n), 'btn-secondary': targetPosition !== String(n) }]"
:class="['grid-btn', { 'is-active': targetPosition === String(n) }]"
@click="targetPosition = String(n)"
>
{{ n }}
</button>
</div>
</div>
</div>
<!-- Zusätzliche Schläge hinzufügen -->
@@ -111,6 +114,7 @@
<div class="next-stroke-selection">
<!-- Schlagart für zusätzlichen Schlag -->
<div class="next-stroke-type">
<span class="group-label">Seite:</span>
<button
type="button"
:class="['btn-small', 'btn-stroke', { 'btn-primary': nextStrokeSide === 'VH', 'btn-secondary': nextStrokeSide !== 'VH' }]"
@@ -129,23 +133,8 @@
</button>
</div>
<!-- Zielposition für Zusatzschlag: explizite Auswahl 19 als Buttons -->
<div class="next-target-selection">
<span>Zielposition:</span>
<div class="target-buttons">
<button
v-for="n in 9"
:key="`next-target-${n}`"
type="button"
:class="['btn-small', { 'btn-primary': nextStrokeTargetPosition === String(n), 'btn-secondary': nextStrokeTargetPosition !== String(n) }]"
@click="nextStrokeTargetPosition = String(n)"
>
{{ n }}
</button>
</div>
</div>
<div class="next-stroke-buttons">
<span class="group-label">Schlagart:</span>
<button
type="button"
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'US', 'btn-secondary': nextStrokeType !== 'US' }]"
@@ -172,25 +161,66 @@
</button>
<button
type="button"
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'FL', 'btn-secondary': nextStrokeType !== 'FL' }]"
@click="nextStrokeType = 'FL'"
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'F', 'btn-secondary': nextStrokeType !== 'F' }]"
@click="nextStrokeType = 'F'"
title="Flip"
>
FL
F
</button>
<button
type="button"
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'BL', 'btn-secondary': nextStrokeType !== 'BL' }]"
@click="nextStrokeType = 'BL'"
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'B', 'btn-secondary': nextStrokeType !== 'B' }]"
@click="nextStrokeType = 'B'"
title="Block"
>
BL
B
</button>
<button
type="button"
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'SCH', 'btn-secondary': nextStrokeType !== 'SCH' }]"
@click="nextStrokeType = 'SCH'"
title="Schuss"
>
SCH
</button>
<button
type="button"
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'SAB', 'btn-secondary': nextStrokeType !== 'SAB' }]"
@click="nextStrokeType = 'SAB'"
title="Schnittabwehr"
>
SAB
</button>
<button
type="button"
:class="['btn-small', 'btn-stroke-type', { 'btn-primary': nextStrokeType === 'BAB', 'btn-secondary': nextStrokeType !== 'BAB' }]"
@click="nextStrokeType = 'BAB'"
title="Ballonabwehr"
>
BAB
</button>
</div>
<!-- Zielposition für Zusatzschlag: explizite Auswahl 19 als Buttons -->
<div class="next-target-selection">
<span>Zielposition:</span>
<div class="target-grid">
<button
v-for="n in 9"
:key="`next-target-${n}`"
type="button"
:class="['grid-btn', { 'is-active': nextStrokeTargetPosition === String(n) }]"
@click="nextStrokeTargetPosition = String(n)"
>
{{ n }}
</button>
</div>
</div>
<button
type="button"
class="btn-primary btn-small"
style="vertical-align:bottom"
@click="addNextStroke"
:disabled="additionalStrokes.length >= 4"
>
Schlag hinzufügen
</button>
@@ -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;
}
</style>

View File

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