einfacher Förderer und Polylinie hinz

This commit is contained in:
Michael Stangl
2026-04-28 14:57:40 +02:00
parent e70af5f548
commit 64ebafae8f
12 changed files with 895 additions and 6 deletions
+13 -2
View File
@@ -1,16 +1,27 @@
import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import './polylinie.css'
import './shapes/shape-types' // Type-Augmentation für Custom Shapes
import { PufferShapeUtil } from './shapes/PufferShapeUtil'
import { KreiselShapeUtil } from './shapes/KreiselShapeUtil'
import { FoerdererShapeUtil } from './shapes/FoerdererShapeUtil'
import { PolylinieShapeUtil } from './shapes/PolylinieShapeUtil'
import { PolylinieTool } from './tools/PolylinieTool'
import { PolylinieBindingUtil } from './bindings/PolylinieBindingUtil'
import { ShapePanel } from './components/ShapePanel'
const customShapeUtils = [PufferShapeUtil, KreiselShapeUtil]
const customShapeUtils = [PufferShapeUtil, KreiselShapeUtil, FoerdererShapeUtil, PolylinieShapeUtil]
const customTools = [PolylinieTool]
const customBindingUtils = [PolylinieBindingUtil]
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<Tldraw shapeUtils={customShapeUtils}>
<Tldraw
shapeUtils={customShapeUtils}
tools={customTools}
bindingUtils={customBindingUtils}
>
<ShapePanel />
</Tldraw>
</div>
@@ -0,0 +1,95 @@
import {
BindingUtil,
TLBaseBinding,
BindingOnShapeChangeOptions,
BindingOnShapeDeleteOptions,
T,
sortByIndex,
} from 'tldraw'
import { PolylinieShape, PolyliniePoint, PolylinieConnectionProps } from '../shapes/shape-types'
export type PolylinieConnectionBinding = TLBaseBinding<
'polylinie-connection',
PolylinieConnectionProps
>
export class PolylinieBindingUtil extends BindingUtil<PolylinieConnectionBinding> {
static override type = 'polylinie-connection' as const
static override props = {
terminal: T.string,
normalizedAnchor: T.object({
x: T.number,
y: T.number,
}),
}
override getDefaultProps(): PolylinieConnectionProps {
return {
terminal: 'end',
normalizedAnchor: { x: 0, y: 0 },
}
}
override onAfterChangeToShape(options: BindingOnShapeChangeOptions<PolylinieConnectionBinding>) {
const { binding, shapeAfter } = options
const polyShape = this.editor.getShape<PolylinieShape>(binding.fromId)
if (!polyShape) return
const targetX = shapeAfter.x
const targetY = shapeAfter.y
const anchor = binding.props.normalizedAnchor
const termPt = this._getTerminalPoint(polyShape, binding.props.terminal)
const dx = targetX + anchor.x - (polyShape.x + termPt.x)
const dy = targetY + anchor.y - (polyShape.y + termPt.y)
if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) return
const points = { ...polyShape.props.points }
const sorted = Object.values(points).sort(sortByIndex)
if (sorted.length < 2) return
const isEnd = binding.props.terminal === 'end'
const terminalIdx = isEnd ? sorted.length - 1 : 0
const terminal = sorted[terminalIdx]
points[terminal.id] = {
...terminal,
x: terminal.x + dx,
y: terminal.y + dy,
}
if (sorted.length >= 3) {
const adjIdx = isEnd ? sorted.length - 2 : 1
const adj = sorted[adjIdx]
points[adj.id] = {
...adj,
x: adj.x + dx * 0.5,
y: adj.y + dy * 0.5,
}
}
this.editor.updateShape<PolylinieShape>({
id: polyShape.id,
type: 'polylinie',
props: { points },
})
}
override onBeforeDeleteToShape(_options: BindingOnShapeDeleteOptions<PolylinieConnectionBinding>) {
// Binding entfernen, Polylinie behalten
}
private _getTerminalPoint(
shape: PolylinieShape,
terminal: string
): { x: number; y: number } {
const sorted = Object.values(shape.props.points).sort(sortByIndex)
if (sorted.length === 0) return { x: 0, y: 0 }
if (terminal === 'start') return sorted[0]
return sorted[sorted.length - 1]
}
}
+39 -2
View File
@@ -1,6 +1,6 @@
import { useEditor } from 'tldraw'
import { useEditor, useValue } from 'tldraw'
import { useCallback } from 'react'
import { PUFFER_CONFIG, KREISEL_CONFIG } from '../shapes/shape-config'
import { PUFFER_CONFIG, KREISEL_CONFIG, FOERDERER_CONFIG } from '../shapes/shape-config'
/** Nächste freie Nummer für einen Shape-Typ ermitteln. */
function nextNumber(editor: ReturnType<typeof useEditor>, type: string): number {
@@ -56,6 +56,30 @@ export function ShapePanel() {
})
}, [editor])
const createFoerderer = useCallback(() => {
const nr = nextNumber(editor, 'foerderer')
const center = editor.getViewportScreenCenter()
const point = editor.screenToPage(center)
editor.createShape({
type: 'foerderer',
x: point.x - FOERDERER_CONFIG.defaultEndX / 2,
y: point.y,
props: {
endX: FOERDERER_CONFIG.defaultEndX,
endY: FOERDERER_CONFIG.defaultEndY,
spacing: FOERDERER_CONFIG.defaultSpacing,
label: `Förderer_${nr}`,
},
})
}, [editor])
const activatePolylinie = useCallback(() => {
editor.setCurrentTool('polylinie')
}, [editor])
const currentToolId = useValue('current tool', () => editor.getCurrentToolId(), [editor])
const isPolylinieActive = currentToolId === 'polylinie'
const syncToServer = useCallback(async () => {
const allShapes = editor.getCurrentPageShapes()
@@ -99,6 +123,19 @@ export function ShapePanel() {
<button onClick={createKreisel} style={btnStyle}>
Kreisel
</button>
<button onClick={createFoerderer} style={btnStyle}>
Förderer
</button>
<button
onClick={activatePolylinie}
style={{
...btnStyle,
background: isPolylinieActive ? '#404040' : '#f8f8f8',
color: isPolylinieActive ? 'white' : 'inherit',
}}
>
Polylinie
</button>
<hr style={{ width: '100%', margin: '4px 0' }} />
<button onClick={syncToServer} style={{ ...btnStyle, background: '#e0f0ff' }}>
Sync Server
+5
View File
@@ -0,0 +1,5 @@
/* Dunkler Hintergrund wenn das Polylinie-Tool aktiv ist */
.tl-container.polylinie-active .tl-canvas {
background-color: #d0d0d0 !important;
transition: background-color 0.2s ease;
}
+205
View File
@@ -0,0 +1,205 @@
import {
ShapeUtil,
TLHandle,
TLShapePartial,
Rectangle2d,
HTMLContainer,
IndexKey,
} from 'tldraw'
import { FoerdererShape, foerdererShapeProps } from './shape-types'
import { FOERDERER_CONFIG as CFG } from './shape-config'
/** Förderer: Doppellinie (durchgezogen + gestrichelt) mit Endpunkt-Kreisen. */
export class FoerdererShapeUtil extends ShapeUtil<FoerdererShape> {
static override type = 'foerderer' as const
static override props = foerdererShapeProps
getDefaultProps(): FoerdererShape['props'] {
return {
endX: CFG.defaultEndX,
endY: CFG.defaultEndY,
spacing: CFG.defaultSpacing,
label: 'Förderer',
}
}
getGeometry(shape: FoerdererShape) {
const { endX, endY, spacing } = shape.props
// Senkrechte zum Richtungsvektor für Spacing-Offset
const len = Math.sqrt(endX * endX + endY * endY) || 1
const perpX = (-endY / len) * spacing
const perpY = (endX / len) * spacing
// Alle 4 Eckpunkte der beiden Linien + Endpunkt-Kreise
const xs = [0, endX, perpX, endX + perpX]
const ys = [0, endY, perpY, endY + perpY]
const pad = CFG.endpointRadius
const minX = Math.min(...xs) - pad
const minY = Math.min(...ys) - pad
const maxX = Math.max(...xs) + pad
const maxY = Math.max(...ys) + pad
return new Rectangle2d({
width: maxX - minX,
height: maxY - minY,
isFilled: false,
})
}
override canResize() {
return false
}
override onRotate(initial: FoerdererShape, current: FoerdererShape): TLShapePartial<FoerdererShape> {
const HALF_PI = Math.PI / 2
const snapped = Math.round(current.rotation / HALF_PI) * HALF_PI
return { id: current.id, type: 'foerderer', x: initial.x, y: initial.y, rotation: snapped }
}
/** Offset vom Shape-Ursprung zum Geometry-Ursprung (wegen negativer Koordinaten) */
private _getOffset(shape: FoerdererShape) {
const { endX, endY, spacing } = shape.props
const len = Math.sqrt(endX * endX + endY * endY) || 1
const perpX = (-endY / len) * spacing
const perpY = (endX / len) * spacing
const xs = [0, endX, perpX, endX + perpX]
const ys = [0, endY, perpY, endY + perpY]
const pad = CFG.endpointRadius
return {
ox: Math.min(...xs) - pad,
oy: Math.min(...ys) - pad,
}
}
override getHandles(shape: FoerdererShape): TLHandle[] {
const { ox, oy } = this._getOffset(shape)
return [
{
id: 'start',
type: 'vertex',
x: 0 - ox,
y: 0 - oy,
index: 'a1' as IndexKey,
canSnap: true,
},
{
id: 'end',
type: 'vertex',
x: shape.props.endX - ox,
y: shape.props.endY - oy,
index: 'a2' as IndexKey,
canSnap: true,
},
]
}
override onHandleDrag(
shape: FoerdererShape,
{ handle }: { handle: TLHandle }
): TLShapePartial<FoerdererShape> | void {
if (handle.id === 'end') {
const { ox, oy } = this._getOffset(shape)
const newEndX = handle.x + ox
const newEndY = handle.y + oy
const len = Math.sqrt(newEndX * newEndX + newEndY * newEndY)
if (len < CFG.minLength) return
return {
id: shape.id,
type: 'foerderer',
props: { ...shape.props, endX: newEndX, endY: newEndY },
}
}
if (handle.id === 'start') {
// Start-Handle: Shape-Position verschieben, End bleibt in Weltkoordinaten
const pagePoint = this.editor.inputs.currentPagePoint
const dx = pagePoint.x - shape.x
const dy = pagePoint.y - shape.y
const newEndX = shape.props.endX - dx
const newEndY = shape.props.endY - dy
const len = Math.sqrt(newEndX * newEndX + newEndY * newEndY)
if (len < CFG.minLength) return
return {
id: shape.id,
type: 'foerderer',
x: pagePoint.x,
y: pagePoint.y,
props: { ...shape.props, endX: newEndX, endY: newEndY },
}
}
}
component(shape: FoerdererShape) {
const { endX, endY, spacing } = shape.props
const { ox, oy } = this._getOffset(shape)
// Senkrechte für parallele Linie
const len = Math.sqrt(endX * endX + endY * endY) || 1
const perpX = (-endY / len) * spacing
const perpY = (endX / len) * spacing
// Punkte relativ zum SVG-Ursprung (offset-korrigiert)
const sx = 0 - ox
const sy = 0 - oy
const ex = endX - ox
const ey = endY - oy
const bounds = this.getGeometry(shape).getBounds()
const totalW = bounds.w
const totalH = bounds.h
return (
<HTMLContainer>
<svg
width={totalW}
height={totalH}
style={{ overflow: 'visible' }}
>
{/* Durchgezogene Linie */}
<line
x1={sx} y1={sy} x2={ex} y2={ey}
stroke="black" strokeWidth={CFG.strokeWidth}
/>
{/* Gestrichelte parallele Linie */}
<line
x1={sx + perpX} y1={sy + perpY}
x2={ex + perpX} y2={ey + perpY}
stroke="black" strokeWidth={CFG.strokeWidth}
strokeDasharray="8 4"
/>
{/* Start-Kreis */}
<circle
cx={sx} cy={sy} r={CFG.endpointRadius}
fill="black" stroke="black" strokeWidth={1}
/>
{/* End-Kreis */}
<circle
cx={ex} cy={ey} r={CFG.endpointRadius}
fill="black" stroke="black" strokeWidth={1}
/>
{/* Label */}
<text
x={(sx + ex) / 2}
y={(sy + ey) / 2 - 12}
textAnchor="middle"
dominantBaseline="central"
fontSize={14}
fill="#666"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{shape.props.label}
</text>
</svg>
</HTMLContainer>
)
}
indicator(shape: FoerdererShape) {
const bounds = this.getGeometry(shape).getBounds()
return (
<rect x={0} y={0} width={bounds.w} height={bounds.h} />
)
}
}
+157
View File
@@ -0,0 +1,157 @@
import {
ShapeUtil,
TLHandle,
TLShapePartial,
Polyline2d,
HTMLContainer,
IndexKey,
sortByIndex,
Vec,
} from 'tldraw'
import { PolylinieShape, PolyliniePoint, polylinieShapeProps } from './shape-types'
import { POLYLINIE_CONFIG as CFG } from './shape-config'
import { snapToAngleGrid } from '../utils/angle-snap'
/** Polylinie: Mehrsegment-Linie mit Winkel-Snapping bei der Bearbeitung. */
export class PolylinieShapeUtil extends ShapeUtil<PolylinieShape> {
static override type = 'polylinie' as const
static override props = polylinieShapeProps
getDefaultProps(): PolylinieShape['props'] {
return {
points: {},
label: 'Polylinie',
}
}
/** Punkte sortiert nach Index zurückgeben. */
private _sortedPoints(shape: PolylinieShape): PolyliniePoint[] {
return Object.values(shape.props.points).sort(sortByIndex)
}
getGeometry(shape: PolylinieShape) {
const pts = this._sortedPoints(shape)
if (pts.length < 2) {
return new Polyline2d({ points: [new Vec(0, 0), new Vec(1, 1)] })
}
return new Polyline2d({
points: pts.map((p) => new Vec(p.x, p.y)),
})
}
override canResize() {
return false
}
override hideRotateHandle() {
return true
}
override hideSelectionBoundsFg() {
return true
}
override hideSelectionBoundsBg() {
return true
}
override getHandles(shape: PolylinieShape): TLHandle[] {
const pts = this._sortedPoints(shape)
return pts.map((p) => ({
id: p.id,
type: 'vertex' as const,
x: p.x,
y: p.y,
index: p.index as IndexKey,
canSnap: true,
}))
}
override onHandleDrag(
shape: PolylinieShape,
{ handle }: { handle: TLHandle }
): TLShapePartial<PolylinieShape> | void {
const pts = this._sortedPoints(shape)
const idx = pts.findIndex((p) => p.id === handle.id)
if (idx < 0) return
// Winkel-Snapping: nur wenn es einen Vorgänger UND Vor-Vorgänger gibt
const prev = idx > 0 ? pts[idx - 1] : null
const prevPrev = idx > 1 ? pts[idx - 2] : null
let newPos = { x: handle.x, y: handle.y }
if (prev && prevPrev) {
newPos = snapToAngleGrid(prev, prevPrev, newPos)
}
// Auch Nachfolger-Richtung berücksichtigen: snap relativ zum Nachfolger
const next = idx < pts.length - 1 ? pts[idx + 1] : null
const nextNext = idx < pts.length - 2 ? pts[idx + 2] : null
if (next && nextNext) {
// Zweiter Snap-Pass für Nachfolger-Kompatibilität (optional, Hauptsnap ist Vorgänger)
}
const updatedPoints = { ...shape.props.points }
updatedPoints[handle.id] = {
...updatedPoints[handle.id],
x: newPos.x,
y: newPos.y,
}
return {
id: shape.id,
type: 'polylinie',
props: { ...shape.props, points: updatedPoints },
}
}
component(shape: PolylinieShape) {
const pts = this._sortedPoints(shape)
if (pts.length < 2) return null
// Bounding-Box berechnen
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const p of pts) {
minX = Math.min(minX, p.x)
minY = Math.min(minY, p.y)
maxX = Math.max(maxX, p.x)
maxY = Math.max(maxY, p.y)
}
const pad = 4
const w = maxX - minX + pad * 2
const h = maxY - minY + pad * 2
const pointStr = pts.map((p) => `${p.x - minX + pad},${p.y - minY + pad}`).join(' ')
return (
<HTMLContainer>
<svg
width={w}
height={h}
style={{
overflow: 'visible',
position: 'absolute',
left: minX - pad,
top: minY - pad,
}}
>
<polyline
points={pointStr}
fill="none"
stroke={CFG.strokeColor}
strokeWidth={CFG.strokeWidth}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
</HTMLContainer>
)
}
indicator(shape: PolylinieShape) {
const pts = this._sortedPoints(shape)
if (pts.length < 2) return null
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
return <path d={d} />
}
}
+16
View File
@@ -24,3 +24,19 @@ export const KREISEL_CONFIG = {
// Maxima
maxAbstand: 4000,
}
export const FOERDERER_CONFIG = {
defaultEndX: 300,
defaultEndY: 0,
defaultSpacing: 20,
minLength: 50,
endpointRadius: 8,
strokeWidth: 2,
}
export const POLYLINIE_CONFIG = {
strokeWidth: 2,
strokeColor: '#000000',
handleRadius: 5,
snapAngles: [-90, -45, -22.5, 0, 22.5, 45, 90],
}
+55 -2
View File
@@ -1,4 +1,4 @@
import { T, TLBaseShape } from 'tldraw'
import { T, TLBaseShape, IndexKey } from 'tldraw'
// --- Puffer ---
export type PufferShapeProps = {
@@ -30,10 +30,63 @@ export const kreiselShapeProps = {
label: T.string,
}
// --- tldraw Type-Augmentation: Custom Shapes registrieren ---
// --- Förderer ---
export type FoerdererShapeProps = {
endX: number
endY: number
spacing: number
label: string
}
export type FoerdererShape = TLBaseShape<'foerderer', FoerdererShapeProps>
export const foerdererShapeProps = {
endX: T.number,
endY: T.number,
spacing: T.number,
label: T.string,
}
// --- Polylinie ---
export type PolyliniePoint = {
id: string
index: IndexKey
x: number
y: number
}
export type PolylinieShapeProps = {
points: Record<string, PolyliniePoint>
label: string
}
export type PolylinieShape = TLBaseShape<'polylinie', PolylinieShapeProps>
export const polylinieShapeProps = {
points: T.dict(T.string, T.object({
id: T.string,
index: T.indexKey,
x: T.number,
y: T.number,
})),
label: T.string,
}
// --- Polylinie-Binding ---
export type PolylinieConnectionProps = {
terminal: string
normalizedAnchor: { x: number; y: number }
}
// --- tldraw Type-Augmentation: Custom Shapes + Bindings registrieren ---
declare module '@tldraw/tlschema' {
interface TLGlobalShapePropsMap {
puffer: PufferShapeProps
kreisel: KreiselShapeProps
foerderer: FoerdererShapeProps
polylinie: PolylinieShapeProps
}
interface TLGlobalBindingPropsMap {
'polylinie-connection': PolylinieConnectionProps
}
}
+212
View File
@@ -0,0 +1,212 @@
import {
StateNode,
TLEventHandlers,
TLShapeId,
createShapeId,
IndexKey,
getIndexAbove,
} from 'tldraw'
import { PolyliniePoint } from '../shapes/shape-types'
import { snapToAngleGrid, Vec2 } from '../utils/angle-snap'
/** Drawing-State: Klick für Klick werden Punkte platziert. Doppelklick beendet. */
export class PolylinieDrawing extends StateNode {
static override id = 'drawing'
private shapeId: TLShapeId = '' as TLShapeId
private points: PolyliniePoint[] = []
private nextIndex: IndexKey = 'a1' as IndexKey
override onEnter() {
const pagePoint = this.editor.inputs.currentPagePoint
this.shapeId = createShapeId()
this.nextIndex = 'a1' as IndexKey
const firstPoint: PolyliniePoint = {
id: 'p0',
index: this.nextIndex,
x: 0,
y: 0,
}
this.nextIndex = getIndexAbove(this.nextIndex)
// Vorschau-Punkt (wird bei Mausbewegung aktualisiert)
const previewPoint: PolyliniePoint = {
id: 'preview',
index: this.nextIndex,
x: 0,
y: 0,
}
this.nextIndex = getIndexAbove(this.nextIndex)
this.points = [firstPoint, previewPoint]
const pointsRecord: Record<string, PolyliniePoint> = {}
for (const p of this.points) {
pointsRecord[p.id] = p
}
this.editor.createShape({
id: this.shapeId,
type: 'polylinie',
x: pagePoint.x,
y: pagePoint.y,
props: {
points: pointsRecord,
label: '',
},
})
}
override onPointerMove: TLEventHandlers['onPointerMove'] = () => {
this._updatePreview()
}
override onPointerDown: TLEventHandlers['onPointerDown'] = () => {
// Den Vorschau-Punkt finalisieren
const preview = this.points[this.points.length - 1]
if (!preview || preview.id !== 'preview') return
const snappedPos = this._getSnappedPosition()
// Vorschau-Punkt wird zum festen Punkt
const fixedId = `p${this.points.length - 1}`
const fixedPoint: PolyliniePoint = {
id: fixedId,
index: preview.index,
x: snappedPos.x,
y: snappedPos.y,
}
// Neuen Vorschau-Punkt erstellen
const newPreview: PolyliniePoint = {
id: 'preview',
index: this.nextIndex,
x: snappedPos.x,
y: snappedPos.y,
}
this.nextIndex = getIndexAbove(this.nextIndex)
// points-Array aktualisieren: Vorschau ersetzen + neuen Vorschau anhängen
this.points[this.points.length - 1] = fixedPoint
this.points.push(newPreview)
this._syncToShape()
}
override onDoubleClick: TLEventHandlers['onDoubleClick'] = () => {
// Vorschau-Punkt entfernen
if (this.points.length > 0 && this.points[this.points.length - 1].id === 'preview') {
this.points.pop()
}
// Den letzten durch Doppelklick platzierten Punkt auch entfernen
// (der erste Klick des Doppelklicks hat bereits einen Punkt gesetzt)
// Mindestens 2 Punkte behalten
if (this.points.length < 2) {
// Zu wenig Punkte → Shape löschen
this.editor.deleteShape(this.shapeId)
} else {
this._syncToShape()
this._tryConnectToShape()
}
this._finish()
}
override onCancel: TLEventHandlers['onCancel'] = () => {
this.editor.deleteShape(this.shapeId)
this._finish()
}
override onKeyDown: TLEventHandlers['onKeyDown'] = (info) => {
if (info.key === 'Escape') {
this.onCancel({} as never)
}
}
/** Aktuelle Mausposition mit Snapping (ab 3. Punkt). */
private _getSnappedPosition(): Vec2 {
const pagePoint = this.editor.inputs.currentPagePoint
const shape = this.editor.getShape(this.shapeId)
if (!shape) return { x: 0, y: 0 }
// In lokale Koordinaten umrechnen
const localX = pagePoint.x - shape.x
const localY = pagePoint.y - shape.y
const fixedPoints = this.points.filter((p) => p.id !== 'preview')
const pointCount = fixedPoints.length
// Erste 2 Punkte: freie Platzierung
if (pointCount < 2) {
return { x: localX, y: localY }
}
// Ab 3. Punkt: Winkel-Snapping
const prev = fixedPoints[pointCount - 1]
const prevPrev = fixedPoints[pointCount - 2]
return snapToAngleGrid(prev, prevPrev, { x: localX, y: localY })
}
/** Vorschau-Punkt an aktuelle Mausposition aktualisieren. */
private _updatePreview() {
const preview = this.points[this.points.length - 1]
if (!preview || preview.id !== 'preview') return
const snapped = this._getSnappedPosition()
preview.x = snapped.x
preview.y = snapped.y
this._syncToShape()
}
/** Points-Array in den Shape-Store schreiben. */
private _syncToShape() {
const pointsRecord: Record<string, PolyliniePoint> = {}
for (const p of this.points) {
pointsRecord[p.id] = p
}
this.editor.updateShape({
id: this.shapeId,
type: 'polylinie',
props: { points: pointsRecord },
})
}
/** Prüfen ob der Endpunkt auf einem Shape-Rand liegt und ggf. Binding erstellen. */
private _tryConnectToShape() {
const shape = this.editor.getShape(this.shapeId)
if (!shape || this.points.length < 2) return
const lastPoint = this.points[this.points.length - 1]
const worldX = shape.x + lastPoint.x
const worldY = shape.y + lastPoint.y
// Shapes am Endpunkt finden
const shapesAtPoint = this.editor.getShapesAtPoint({ x: worldX, y: worldY })
const connectable = shapesAtPoint.find(
(s) => ['puffer', 'kreisel', 'foerderer'].includes(s.type) && s.id !== this.shapeId
)
if (connectable) {
// Binding erstellen
this.editor.createBinding({
type: 'polylinie-connection',
fromId: this.shapeId,
toId: connectable.id,
props: {
terminal: 'end',
normalizedAnchor: { x: worldX, y: worldY },
},
})
}
}
/** Zeichnung beenden, zurück zum Idle-State. */
private _finish() {
document.querySelector('.tl-container')?.classList.remove('polylinie-active')
this.points = []
this.parent.transition('idle')
}
}
+30
View File
@@ -0,0 +1,30 @@
import { StateNode, TLEventHandlers } from 'tldraw'
/** Idle-State: Wartet auf den ersten Klick zum Zeichnen. */
export class PolylinieIdle extends StateNode {
static override id = 'idle'
override onEnter() {
this.editor.setCursor({ type: 'cross', rotation: 0 })
document.querySelector('.tl-container')?.classList.add('polylinie-active')
}
override onExit() {
// Hintergrund nur entfernen wenn wir nicht in den Drawing-State wechseln
}
override onPointerDown: TLEventHandlers['onPointerDown'] = () => {
this.parent.transition('drawing')
}
override onCancel: TLEventHandlers['onCancel'] = () => {
document.querySelector('.tl-container')?.classList.remove('polylinie-active')
this.editor.setCurrentTool('select')
}
override onKeyDown: TLEventHandlers['onKeyDown'] = (info) => {
if (info.key === 'Escape') {
this.onCancel({} as never)
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import { StateNode } from 'tldraw'
import { PolylinieIdle } from './PolylinieIdle'
import { PolylinieDrawing } from './PolylinieDrawing'
/** Custom Tool zum Zeichnen von Polylinien. */
export class PolylinieTool extends StateNode {
static override id = 'polylinie'
static override initial = 'idle'
static override children() {
return [PolylinieIdle, PolylinieDrawing]
}
}
+56
View File
@@ -0,0 +1,56 @@
import { POLYLINIE_CONFIG } from '../shapes/shape-config'
export interface Vec2 {
x: number
y: number
}
/**
* Snapped einen Kandidatenpunkt auf das Winkelraster relativ zur vorherigen Segment-Richtung.
*
* - Wenn prevPrev null ist (erste 2 Segmente): freie Platzierung, kein Snapping.
* - Ab dem 3. Punkt: Snap auf [-90, -45, -22.5, 0, +22.5, +45, +90]° relativ zum vorherigen Segment.
*/
export function snapToAngleGrid(
prev: Vec2,
prevPrev: Vec2 | null,
candidate: Vec2,
snapAngles: number[] = POLYLINIE_CONFIG.snapAngles,
): Vec2 {
if (!prevPrev) return candidate
// Richtung des vorherigen Segments
const prevDir = Math.atan2(prev.y - prevPrev.y, prev.x - prevPrev.x)
// Richtung zum Kandidaten
const candDir = Math.atan2(candidate.y - prev.y, candidate.x - prev.x)
// Relativer Winkel in Grad
let relDeg = ((candDir - prevDir) * 180) / Math.PI
// Normalisieren auf [-180, 180]
relDeg = ((relDeg + 180) % 360 + 360) % 360 - 180
// Nächsten Snap-Winkel finden
let bestSnap = snapAngles[0]
let bestDiff = Infinity
for (const snap of snapAngles) {
const diff = Math.abs(relDeg - snap)
if (diff < bestDiff) {
bestDiff = diff
bestSnap = snap
}
}
// Absolute Richtung nach Snap
const snappedAbsAngle = prevDir + (bestSnap * Math.PI) / 180
// Distanz vom prev zum Kandidaten beibehalten
const dist = Math.sqrt(
(candidate.x - prev.x) ** 2 + (candidate.y - prev.y) ** 2
)
return {
x: prev.x + Math.cos(snappedAbsAngle) * dist,
y: prev.y + Math.sin(snappedAbsAngle) * dist,
}
}