Build image / build-and-push (push) Successful in 4s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
93 lines
2.9 KiB
TypeScript
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>
|
|
)
|
|
})
|