einfacher Förderer und Polylinie hinz
This commit is contained in:
+13
-2
@@ -1,16 +1,27 @@
|
|||||||
import { Tldraw } from 'tldraw'
|
import { Tldraw } from 'tldraw'
|
||||||
import 'tldraw/tldraw.css'
|
import 'tldraw/tldraw.css'
|
||||||
|
import './polylinie.css'
|
||||||
import './shapes/shape-types' // Type-Augmentation für Custom Shapes
|
import './shapes/shape-types' // Type-Augmentation für Custom Shapes
|
||||||
import { PufferShapeUtil } from './shapes/PufferShapeUtil'
|
import { PufferShapeUtil } from './shapes/PufferShapeUtil'
|
||||||
import { KreiselShapeUtil } from './shapes/KreiselShapeUtil'
|
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'
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'fixed', inset: 0 }}>
|
<div style={{ position: 'fixed', inset: 0 }}>
|
||||||
<Tldraw shapeUtils={customShapeUtils}>
|
<Tldraw
|
||||||
|
shapeUtils={customShapeUtils}
|
||||||
|
tools={customTools}
|
||||||
|
bindingUtils={customBindingUtils}
|
||||||
|
>
|
||||||
<ShapePanel />
|
<ShapePanel />
|
||||||
</Tldraw>
|
</Tldraw>
|
||||||
</div>
|
</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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEditor } from 'tldraw'
|
import { useEditor, useValue } from 'tldraw'
|
||||||
import { useCallback } from 'react'
|
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. */
|
/** Nächste freie Nummer für einen Shape-Typ ermitteln. */
|
||||||
function nextNumber(editor: ReturnType<typeof useEditor>, type: string): number {
|
function nextNumber(editor: ReturnType<typeof useEditor>, type: string): number {
|
||||||
@@ -56,6 +56,30 @@ export function ShapePanel() {
|
|||||||
})
|
})
|
||||||
}, [editor])
|
}, [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 syncToServer = useCallback(async () => {
|
||||||
const allShapes = editor.getCurrentPageShapes()
|
const allShapes = editor.getCurrentPageShapes()
|
||||||
|
|
||||||
@@ -99,6 +123,19 @@ export function ShapePanel() {
|
|||||||
<button onClick={createKreisel} style={btnStyle}>
|
<button onClick={createKreisel} style={btnStyle}>
|
||||||
◎ Kreisel
|
◎ Kreisel
|
||||||
</button>
|
</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' }} />
|
<hr style={{ width: '100%', margin: '4px 0' }} />
|
||||||
<button onClick={syncToServer} style={{ ...btnStyle, background: '#e0f0ff' }}>
|
<button onClick={syncToServer} style={{ ...btnStyle, background: '#e0f0ff' }}>
|
||||||
↑ Sync Server
|
↑ Sync Server
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,3 +24,19 @@ export const KREISEL_CONFIG = {
|
|||||||
// Maxima
|
// Maxima
|
||||||
maxAbstand: 4000,
|
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],
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { T, TLBaseShape } from 'tldraw'
|
import { T, TLBaseShape, IndexKey } from 'tldraw'
|
||||||
|
|
||||||
// --- Puffer ---
|
// --- Puffer ---
|
||||||
export type PufferShapeProps = {
|
export type PufferShapeProps = {
|
||||||
@@ -30,10 +30,63 @@ export const kreiselShapeProps = {
|
|||||||
label: T.string,
|
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' {
|
declare module '@tldraw/tlschema' {
|
||||||
interface TLGlobalShapePropsMap {
|
interface TLGlobalShapePropsMap {
|
||||||
puffer: PufferShapeProps
|
puffer: PufferShapeProps
|
||||||
kreisel: KreiselShapeProps
|
kreisel: KreiselShapeProps
|
||||||
|
foerderer: FoerdererShapeProps
|
||||||
|
polylinie: PolylinieShapeProps
|
||||||
|
}
|
||||||
|
interface TLGlobalBindingPropsMap {
|
||||||
|
'polylinie-connection': PolylinieConnectionProps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user