Verbessert die Logik zur Erstellung von Aktivitäten im DiaryDateActivityService, um PredefinedActivities robuster zu finden. Fügt Unterstützung für die Suche nach Aktivitäten per ID, Name oder Code hinzu. Aktualisiert die Benutzeroberfläche in DiaryView.vue zur Anzeige von Zeichnungsdaten und integriert ein neues Rendering-Modal für Zeichnungen. Optimiert die Bildanzeige in CourtDrawingTool.vue und implementiert eine verbesserte Fehlerbehandlung beim Laden von Bildern.

This commit is contained in:
Torsten Schulz (local)
2025-09-23 14:40:41 +02:00
parent eb2273e28c
commit b557297bf0
4 changed files with 543 additions and 31 deletions

View File

@@ -0,0 +1,420 @@
<template>
<div class="render-container">
<canvas
ref="canvas"
:width="config.canvas.width"
:height="config.canvas.height"
style="margin:0 auto;"
></canvas>
</div>
</template>
<script>
export default {
name: 'CourtDrawingRender',
props: {
drawingData: {
type: Object,
required: true
},
width: {
type: Number,
default: 600
},
height: {
type: Number,
default: 400
}
},
data() {
return {
canvas: null,
ctx: null,
config: {
// Mehr Platz links für die drei Startkreise (Rand außerhalb des Tisches)
canvas: { width: 640, height: 400 },
table: {
// Tabelle etwas schmaler und weiter rechts platzieren
width: 500,
height: 300,
color: '#2d5a2d',
borderWidth: 2,
borderColor: '#000000',
outerFrameWidth: 5,
outerFrameColor: '#ffffff',
outerFrameBorderWidth: 0.5,
outerFrameBorderColor: '#000000'
},
horizontalLine: { color: '#cfd8dc', width: 2, gap: 10, edgeMargin: 10 },
net: { color: '#ffffff', width: 4, overhang: 14, borderColor: '#000000', borderWidth: 0.5 },
serviceLine: { color: 'rgba(255,255,255,0.0)', width: 0 },
startCircles: {
radius: 10,
// Abstand vom linken Canvas-Rand (größer = weiter links, da wir von tableX abziehen)
x: 20,
topYOffset: 35,
bottomYOffset: 35,
selectedColor: '#ff4444',
unselectedColor: '#9e9e9e',
selectedBorderColor: '#ffffff',
unselectedBorderColor: '#555555',
selectedBorderWidth: 2,
unselectedBorderWidth: 1
},
arrows: {
primaryColor: '#d32f2f', // rechts -> target (rot)
secondaryColor: '#1565c0', // zurück (blau)
width: 6,
headLength: 24
},
targetCircles: {
radius: 13,
topYOffset: 45,
bottomYOffset: 45,
transparency: 0.25
},
hitMarker: {
radius: 10.5,
fill: '#ffffff',
stroke: '#000000',
lineWidth: 1
}
}
};
},
watch: {
drawingData: {
handler() {
this.redraw();
},
deep: true,
immediate: true
}
},
mounted() {
// apply custom dimensions if provided
this.config.canvas.width = this.width || 600;
this.config.canvas.height = this.height || 400;
this.$nextTick(() => {
console.log('CourtDrawingRender: mounted with drawingData =', this.drawingData);
this.init();
this.redraw();
});
},
methods: {
init() {
this.canvas = this.$refs.canvas;
if (this.canvas) {
this.ctx = this.canvas.getContext('2d');
console.log('CourtDrawingRender: canvas/context initialized');
}
},
redraw() {
console.log('CourtDrawingRender: redraw called with data =', this.drawingData);
if (!this.ctx) return;
const { width, height } = this.config.canvas;
// clear and background
this.ctx.clearRect(0, 0, width, height);
this.ctx.fillStyle = '#f0f0f0';
this.ctx.fillRect(0, 0, width, height);
this.drawTable();
this.drawStartCircles();
this.drawArrowsAndLabels();
},
drawTable() {
const ctx = this.ctx;
const { table, horizontalLine, net, outerFrameWidth } = {
...this.config,
outerFrameWidth: this.config.table.outerFrameWidth
};
const tableWidth = this.config.table.width;
const tableHeight = this.config.table.height;
const tableX = (this.config.canvas.width - tableWidth) / 2;
const tableY = (this.config.canvas.height - tableHeight) / 2;
// table fill
ctx.fillStyle = table.color;
ctx.fillRect(tableX, tableY, tableWidth, tableHeight);
// table stroke
ctx.strokeStyle = table.borderColor;
ctx.lineWidth = table.borderWidth;
ctx.strokeRect(tableX, tableY, tableWidth, tableHeight);
// outer white frame
ctx.strokeStyle = table.outerFrameColor;
ctx.lineWidth = table.outerFrameWidth;
ctx.strokeRect(
tableX - table.outerFrameWidth,
tableY - table.outerFrameWidth,
tableWidth + table.outerFrameWidth * 2,
tableHeight + table.outerFrameWidth * 2
);
// black thin frame around white
ctx.strokeStyle = table.outerFrameBorderColor;
ctx.lineWidth = table.outerFrameBorderWidth;
ctx.strokeRect(
tableX - table.outerFrameWidth - table.outerFrameBorderWidth,
tableY - table.outerFrameWidth - table.outerFrameBorderWidth,
tableWidth + (table.outerFrameWidth + table.outerFrameBorderWidth) * 2,
tableHeight + (table.outerFrameWidth + table.outerFrameBorderWidth) * 2
);
// horizontal split line (two segments)
ctx.strokeStyle = horizontalLine.color;
ctx.lineWidth = horizontalLine.width;
const centerY = tableY + tableHeight / 2;
const centerX = tableX + tableWidth / 2;
const gap = horizontalLine.gap;
const edge = horizontalLine.edgeMargin;
ctx.beginPath();
ctx.moveTo(tableX + edge, centerY);
ctx.lineTo(centerX - gap, centerY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(centerX + gap, centerY);
ctx.lineTo(tableX + tableWidth - edge, centerY);
ctx.stroke();
// net vertical
ctx.strokeStyle = net.color;
ctx.lineWidth = net.width;
ctx.beginPath();
ctx.moveTo(centerX, tableY - net.overhang);
ctx.lineTo(centerX, tableY + tableHeight + net.overhang);
ctx.stroke();
ctx.strokeStyle = net.borderColor;
ctx.lineWidth = net.borderWidth;
ctx.beginPath();
ctx.moveTo(centerX, tableY - net.overhang);
ctx.lineTo(centerX, tableY + tableHeight + net.overhang);
ctx.stroke();
},
drawStartCircles() {
const ctx = this.ctx;
const cfg = this.config.startCircles;
const tableWidth = this.config.table.width;
const tableHeight = this.config.table.height;
const tableX = (this.config.canvas.width - tableWidth) / 2;
const tableY = (this.config.canvas.height - tableHeight) / 2;
// Startkreis links VOR dem Tisch positionieren nur den Kreis zeichnen,
// von dem auch der rote Pfeil startet
const circleX = tableX - cfg.x; // Links vom Tisch
const topY = tableY + cfg.topYOffset;
const midY = tableY + tableHeight / 2;
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 y = selectedPos === 'top' ? topY : selectedPos === 'bottom' ? botY : midY;
ctx.fillStyle = cfg.selectedColor;
ctx.beginPath();
ctx.arc(circleX, y, cfg.radius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = cfg.selectedBorderColor;
ctx.lineWidth = cfg.selectedBorderWidth;
ctx.stroke();
},
computeRightTargetPosition(number) {
// replicate the same geometry as in the drawing tool
const tableWidth = this.config.table.width;
const tableHeight = this.config.table.height;
const tableX = (this.config.canvas.width - tableWidth) / 2;
const tableY = (this.config.canvas.height - tableHeight) / 2;
const centerX = tableX + tableWidth / 2;
const cfg = this.config.targetCircles;
const x1 = tableX + tableWidth - 30; // rightmost column (1,2,3)
const x3 = centerX + (x1 - centerX) * 0.35; // near net (7,8,9)
const x2 = (x1 + x3) / 2; // middle column (4,5,6)
const positions = {
1: { x: x1, y: tableY + cfg.topYOffset },
2: { x: x1, y: tableY + tableHeight / 2 },
3: { x: x1, y: tableY + tableHeight - cfg.bottomYOffset },
4: { x: x2, y: tableY + cfg.topYOffset },
5: { x: x2, y: tableY + tableHeight / 2 },
6: { x: x2, y: tableY + tableHeight - cfg.bottomYOffset },
7: { x: x3, y: tableY + cfg.topYOffset },
8: { x: x3, y: tableY + tableHeight / 2 },
9: { x: x3, y: tableY + tableHeight - cfg.bottomYOffset }
};
return positions[number] || null;
},
getStartPoint() {
const cfg = this.config.startCircles;
const tableWidth = this.config.table.width;
const tableHeight = this.config.table.height;
const tableX = (this.config.canvas.width - tableWidth) / 2;
const tableY = (this.config.canvas.height - tableHeight) / 2;
const map = {
AS1: 'top', AS2: 'middle', AS3: 'bottom', AS: 'middle'
};
const selected = map[this.drawingData?.selectedStartPosition] || 'middle';
const circleX = tableX - cfg.x; // Links vom Tisch
const topY = tableY + cfg.topYOffset;
const midY = tableY + tableHeight / 2;
const botY = tableY + tableHeight - cfg.bottomYOffset;
const y = selected === 'top' ? topY : selected === 'bottom' ? botY : midY;
// arrow should start slightly to the right of the start circle
return { x: circleX + cfg.radius + 6, y };
},
drawArrow(ctx, from, to, color, label) {
const { width, headLength } = this.config.arrows;
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = width;
// line
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
// arrow head
const angle = Math.atan2(to.y - from.y, to.x - from.x);
ctx.beginPath();
ctx.moveTo(to.x, to.y);
ctx.lineTo(to.x - headLength * Math.cos(angle - Math.PI / 6), to.y - headLength * Math.sin(angle - Math.PI / 6));
ctx.lineTo(to.x - headLength * Math.cos(angle + Math.PI / 6), to.y - headLength * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fill();
},
drawLabelBelow(ctx, text, anchor) {
if (!text) return;
ctx.font = 'bold 13px Helvetica';
const padding = 6;
const yOffset = 23; // 5px tiefer
const metrics = ctx.measureText(text);
const textWidth = metrics.width;
let x = anchor.x - textWidth / 2; // zentriert unter dem Punkt
let y = anchor.y + yOffset;
// clamp innerhalb Canvas
x = Math.max(4, Math.min(this.config.canvas.width - textWidth - 4, x));
y = Math.max(14, Math.min(this.config.canvas.height - 4, y));
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 2;
ctx.strokeText(text, x, y);
ctx.fillText(text, x, y);
},
drawArrowsAndLabels() {
if (!this.drawingData) return;
const ctx = this.ctx;
const from = this.getStartPoint();
// First arrow: to right target
const tp = Number(this.drawingData.targetPosition);
if (tp) {
const to = this.computeRightTargetPosition(tp);
// Zielmarkierung (unter dem Pfeilkopf)
this.drawHitMarker(ctx, to);
const strokeSide = this.drawingData.strokeType || '';
const spinAbbrev = this.abbrevSpin(this.drawingData.spinType);
// Text gehört an die Quelle (ohne "target")
const sourceLabel = `${strokeSide} ${spinAbbrev}`.trim();
this.drawArrow(ctx, from, to, this.config.arrows.primaryColor);
// Unter dem Startkreis beschriften
const startCenter = this.getStartCircleCenter();
this.drawLabelBelow(ctx, sourceLabel, startCenter);
}
// Second arrow (optional): from right source to left target
const leftTarget = this.drawingData.nextStrokeTargetPosition ? Number(this.drawingData.nextStrokeTargetPosition) : null;
if (tp && leftTarget) {
// source near previous right target
const sourceRight = this.computeRightTargetPosition(tp);
// left target mapping: mirror scheme to left half
const toLeft = this.computeLeftTargetPosition(leftTarget);
// Zielmarkierung links
this.drawHitMarker(ctx, toLeft);
const side = this.drawingData.nextStrokeSide || '';
const type = this.drawingData.nextStrokeType || '';
// Text gehört ans Ziel (ohne "extra target")
const targetLabel = `${side} ${type}`.trim();
this.drawArrow(ctx, sourceRight, toLeft, this.config.arrows.secondaryColor);
// Unter dem rechten Ziel (target der ersten Linie) beschriften
this.drawLabelBelow(ctx, targetLabel, sourceRight);
}
},
getStartCircleCenter() {
const cfg = this.config.startCircles;
const tableWidth = this.config.table.width;
const tableHeight = this.config.table.height;
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 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 };
},
drawHitMarker(ctx, pos) {
if (!pos) return;
const mk = this.config.hitMarker;
ctx.beginPath();
ctx.arc(pos.x, pos.y, mk.radius, 0, Math.PI * 2);
ctx.fillStyle = mk.fill;
ctx.fill();
ctx.lineWidth = mk.lineWidth;
ctx.strokeStyle = mk.stroke;
ctx.stroke();
},
computeLeftTargetPosition(number) {
// mirror target grid to left side
const tableWidth = this.config.table.width;
const tableHeight = this.config.table.height;
const tableX = (this.config.canvas.width - tableWidth) / 2;
const tableY = (this.config.canvas.height - tableHeight) / 2;
const centerX = tableX + tableWidth / 2;
const cfg = this.config.targetCircles;
const x1 = tableX + 30; // leftmost column
const x3 = centerX - (centerX - x1) * 0.35; // near net
const x2 = (x1 + x3) / 2;
const positions = {
1: { x: x1, y: tableY + cfg.topYOffset },
2: { x: x1, y: tableY + tableHeight / 2 },
3: { x: x1, y: tableY + tableHeight - cfg.bottomYOffset },
4: { x: x2, y: tableY + cfg.topYOffset },
5: { x: x2, y: tableY + tableHeight / 2 },
6: { x: x2, y: tableY + tableHeight - cfg.bottomYOffset },
7: { x: x3, y: tableY + cfg.topYOffset },
8: { x: x3, y: tableY + tableHeight / 2 },
9: { x: x3, y: tableY + tableHeight - cfg.bottomYOffset }
};
return positions[number] || null;
},
abbrevSpin(spin) {
if (!spin) return '';
const map = {
Unterschnitt: 'US',
Überschnitt: 'OS',
Seitschnitt: 'SR',
Seitunterschnitt: 'SU',
Gegenläufer: 'GL'
};
return map[spin] || spin;
}
}
};
</script>
<style scoped>
.render-container {
width: 100%;
}
canvas { display: block; max-width: 100%; height: auto; }
</style>

View File

@@ -588,8 +588,12 @@ export default {
this.drawArrowToTarget(ctx, tableX, tableY, tableWidth, tableHeight);
}
// Kreise für zusätzliche Schläge auf der linken Seite anzeigen (nur nach Schlag-Auswahl für rechte Seite)
if (this.nextStrokeType && this.nextStrokeSide && this.targetPosition) {
// Kreise für zusätzliche Schläge auf der linken Seite anzeigen
// Sollen bereits erscheinen, sobald rechts alles gewählt ist (Aufschlag-Seite):
// also wenn strokeType, spinType und targetPosition gesetzt sind.
// Zusätzlich weiterhin, wenn bereits nextStrokeType/Side gewählt wurden.
if ((this.strokeType && this.spinType && this.targetPosition) ||
(this.nextStrokeType && this.nextStrokeSide && this.targetPosition)) {
this.drawLeftSideTargetCircles(ctx, tableX, tableY, tableWidth, tableHeight);
}

View File

@@ -126,10 +126,11 @@
</span>
<span v-else @click="startActivityEdit(item)" class="clickable activity-label"
:title="item.predefinedActivity && item.predefinedActivity.name ? item.predefinedActivity.name : ''">
<span v-if="item.predefinedActivity && item.predefinedActivity.imageLink"
@click.stop="showActivityImage(item.predefinedActivity.imageLink)"
class="image-icon"
title="Bild anzeigen">🖼</span>
<!-- Icon öffnet Rendering (falls vorhanden) oder Bild im Modal -->
<span v-if="item.predefinedActivity"
@click.stop="openActivityVisual(item.predefinedActivity)"
class="image-icon"
title="Bild/Zeichnung anzeigen">🖼</span>
{{ (item.predefinedActivity && item.predefinedActivity.code && item.predefinedActivity.code.trim() !== '')
? item.predefinedActivity.code
: (item.predefinedActivity ? item.predefinedActivity.name : item.activity) }}
@@ -161,10 +162,12 @@
<td></td>
<td>
<span class="activity-label" :title="(groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.name) ? groupItem.groupPredefinedActivity.name : ''">
<span v-if="groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.imageLink"
@click.stop="showActivityImage(groupItem.groupPredefinedActivity.imageLink)"
class="image-icon"
title="Bild anzeigen">🖼</span>
<!-- Icon öffnet Rendering (falls vorhanden) oder Bild im Modal -->
<span v-if="groupItem.groupPredefinedActivity"
@click.stop="openActivityVisual(groupItem.groupPredefinedActivity)"
class="image-icon"
title="Bild/Zeichnung anzeigen">🖼</span>
{{ (groupItem.groupPredefinedActivity && groupItem.groupPredefinedActivity.code && groupItem.groupPredefinedActivity.code.trim() !== '')
? groupItem.groupPredefinedActivity.code
: groupItem.groupPredefinedActivity.name }}
@@ -319,8 +322,13 @@
</div>
</div>
</div>
<div v-if="showImage" class="memberImage">
<img :src="imageUrl" @click="closeImage" />
<div v-if="showImage || showRenderModal" class="memberImage" @click="closeImage">
<template v-if="showImage">
<img :src="imageUrl" />
</template>
<template v-else>
<CourtDrawingRender :drawing-data="renderModalData" :width="560" :height="360" />
</template>
</div>
<div v-if="showAccidentForm" class="accidentForm">
<form @submit.prevent="submitAccident">
@@ -397,10 +405,11 @@ import apiClient from '../apiClient.js';
import Multiselect from 'vue-multiselect';
import Sortable from 'sortablejs';
import PDFGenerator from '../components/PDFGenerator.js';
import CourtDrawingRender from '../components/CourtDrawingRender.vue';
export default {
name: 'DiaryView',
components: { Multiselect },
components: { Multiselect, CourtDrawingRender },
data() {
return {
date: null,
@@ -436,6 +445,8 @@ export default {
showDropdown: false,
showImage: false,
imageUrl: '',
showRenderModal: false,
renderModalData: null,
groups: [],
currentTimeBlockId: null,
newGroupName: '',
@@ -526,6 +537,36 @@ export default {
},
},
methods: {
drawingDataFor(pa) {
// Zeichnungsdaten können bereits als Objekt vorliegen oder als JSON-String
try {
if (!pa) return null;
if (pa.drawingData && typeof pa.drawingData === 'object') {
console.debug('DiaryView: drawingData (object) gefunden für', pa.id);
return pa.drawingData;
}
if (pa.drawingData && typeof pa.drawingData === 'string') {
const parsed = JSON.parse(pa.drawingData);
console.debug('DiaryView: drawingData (string→parsed) für', pa.id, parsed);
return parsed;
}
// Fallback: falls über images[0].drawingData geliefert wurde
if (pa.images && pa.images.length) {
// Nimm das erste Bild, das drawingData enthält
const withData = pa.images.find(img => !!img.drawingData);
if (withData) {
const parsedImg = typeof withData.drawingData === 'string'
? JSON.parse(withData.drawingData)
: withData.drawingData;
console.debug('DiaryView: drawingData aus images für', pa.id, 'imageId=', withData.id, parsedImg);
return parsedImg;
}
}
} catch (e) {
console.warn('DiaryView: drawingData parse error:', e);
}
return null;
},
async init() {
if (this.isAuthenticated && this.currentClub) {
const response = await apiClient.get(`/diary/${this.currentClub}`);
@@ -1119,17 +1160,43 @@ export default {
},
closeImage() {
this.showImage = false;
this.showRenderModal = false;
this.renderModalData = null;
this.imageUrl = '';
},
showActivityImage(imageLink) {
// Erstelle vollständige URL mit korrektem Port
if (imageLink && imageLink.startsWith('/api/')) {
this.imageUrl = `http://localhost:3000${imageLink}`;
} else {
this.imageUrl = imageLink;
async showActivityImage(imageLink) {
try {
// Vorherige Object-URL freigeben
if (this.imageUrl && this.imageUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.imageUrl);
}
if (imageLink && imageLink.startsWith('/api/')) {
// Über API mit Auth und als Blob laden → vermeidet ORB
const path = imageLink.replace(/^\/api\//, '');
const resp = await apiClient.get(`/${path}`, { responseType: 'blob' });
this.imageUrl = URL.createObjectURL(resp.data);
} else {
this.imageUrl = imageLink;
}
this.showImage = true;
} catch (e) {
console.error('Bild laden fehlgeschlagen:', e);
alert('Bild konnte nicht geladen werden.');
}
},
async openActivityVisual(pa) {
// Bevorzugt Rendering, ansonsten Bild
const data = this.drawingDataFor(pa);
if (data) {
// Erzeuge temporär ein PNG im Client: Canvas offscreen rendern über Renderer-Komponente ist aufwändig.
// Einfacher: öffne ein kleines Modal mit der Renderer-Komponente statt imageUrl.
this.renderModalData = data;
this.showRenderModal = true;
} else if (pa.imageLink) {
await this.showActivityImage(pa.imageLink);
}
this.showImage = true;
},
async loadMemberImage(member) {
try {
@@ -1915,6 +1982,12 @@ img {
margin: 0 auto;
}
.memberImage > div, .memberImage canvas {
/* falls Komponenten-Inhalt (Renderer) da ist, mittig zeigen */
display: block;
margin: 0 auto;
}
.groups {
display: flex;
flex-direction: row;