einfacher Förderer und Polylinie hinz
This commit is contained in:
+13
-2
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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 ---
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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