Files
pansy/web/src/editor/PlopMarker.tsx
T
steve 245e0cbe71
Build image / build-and-push (push) Successful in 4s
Polish: imperial, clear-bed, keyboard nudging, empty states (#18)
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 04:44:26 +00:00

93 lines
2.9 KiB
TypeScript

import { memo, type PointerEvent } from 'react'
import type { Plant } from '@/lib/plants'
import { computeDerivedCount, type EditorPlanting } from '@/lib/plantings'
import { SELECT_COLOR } from './shared'
// Semantic-zoom thresholds in px/cm (DESIGN § Editor / rendering), tuned by feel:
// < FAR — flat color patch, no text ("what's planted where" at a glance)
// FAR..NEAR — colored circle + plant emoji
// ≥ NEAR — circle + emoji + plant name + count
export const SEMANTIC_FAR = 0.75
export const SEMANTIC_NEAR = 3
const DEFAULT_COLOR = '#6aa84f'
/**
* One plop, rendered in its parent object's local frame (the caller wraps it in
* the object's translate+rotate group, so the plop tracks the bed). A circle in
* the plant's color; emoji and name/count fade in with zoom. The count is
* computed live from the current radius so it updates while resizing. memo'd so a
* pan/zoom that only changes the world transform doesn't re-render every plop.
*/
export const PlopMarker = memo(function PlopMarker({
plop,
plant,
scale,
selected,
onSelect,
}: {
plop: EditorPlanting
plant?: Plant
scale: number
selected: boolean
onSelect: (id: number) => void
}) {
const color = plant?.color ?? DEFAULT_COLOR
const r = Math.max(0, plop.radiusCm)
const showIcon = scale >= SEMANTIC_FAR && !!plant?.icon
const showText = scale >= SEMANTIC_NEAR && !!plant
const count = plop.count ?? (plant ? computeDerivedCount(plop.radiusCm, plant.spacingCm) : plop.derivedCount)
// Keep the tap target at least ~44px across even when the plop draws tiny at
// low zoom (fill=transparent still receives pointer events, unlike fill=none).
const hitR = Math.max(r, 22 / scale)
function down(e: PointerEvent) {
e.stopPropagation()
onSelect(plop.id)
}
return (
<g transform={`translate(${plop.xCm} ${plop.yCm})`} onPointerDown={down} style={{ cursor: 'pointer' }}>
<circle cx={0} cy={0} r={hitR} fill="transparent" />
<circle
cx={0}
cy={0}
r={r}
fill={color}
fillOpacity={0.82}
stroke={selected ? SELECT_COLOR : '#00000033'}
strokeWidth={selected ? 2.5 : 1}
vectorEffect="non-scaling-stroke"
/>
{showIcon && (
<text
x={0}
y={0}
fontSize={r * 1.2}
textAnchor="middle"
dominantBaseline="central"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{plant!.icon}
</text>
)}
{showText && (
<text
x={0}
y={r + r * 0.3}
fontSize={Math.max(r * 0.5, 6)}
textAnchor="middle"
dominantBaseline="hanging"
fill="#1f2937"
stroke="#ffffff"
strokeWidth={0.5}
paintOrder="stroke"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{plant!.name} · {count}
</text>
)}
</g>
)
})