Browse documentation

Lidar scan

A polar range display for real or simulated lidar samples. Returns stay in sensor coordinates and rotate with its heading.

90°270°RANGE 10 m
variant
rays
heading
scan
60°
range
10 m

Sample data: a 14 × 10 m room and one nearby obstacle. Reduce range to filter distant returns.

Install

pnpm dlx shadcn@latest add https://robocn.dev/r/lidar-scan.json

Notes

  • The component does not generate samples. The demo supplies a deterministic room outline and obstacle, clearly labelled as sample data.
  • Negative, non-finite, and beyond-range distances are omitted. Non-finite angles are omitted. Zero-distance returns are valid and plot at the origin.
  • Changing scanAngle only moves the ray; it does not filter or age returns. Supply a new samples array when fresh sensor data arrives.

Usage

import { LidarScan } from "@/components/ui/lidar-scan"

<LidarScan
  samples={[{ angle: 0, distance: 5 }, { angle: 90, distance: 8 }]}
  maxRange={10}
  heading={20}
  scanAngle={60}
  showRays
/>

Props

PropTypeDefaultDescription
samplesreadonly LidarSample[][]Angle-distance pairs. Angles run clockwise from sensor-forward; distance uses the same units as maxRange.
maxRangenumber10Distance represented by the outer ring. Non-finite or nonpositive values use 10.
headingnumber0Clockwise sensor heading in degrees. Rotates samples and scan ray together; non-finite values use zero.
scanAnglenumberScan-ray angle relative to sensor-forward. Omit or supply a non-finite value to hide the ray.
showRaysbooleanfalseDraw a line from the sensor to each valid return.
showRingsbooleantrueQuarter-range rings and crosshairs.
labelstringBottom caption; defaults to the maximum range.
variant"solid" | "outline" | "blueprint" | "wire""solid"How the machine is painted. Geometry never changes between variants.
size"xs" | "sm" | "md" | "lg" | "xl" | number"md"Rendered width in pixels, or a step on the scale.
colorstringvar(--robot-shell)Body panels — the colour the machine reads as.
accentstringvar(--robot-accent)Status colour: tip light, live tool, readouts.
metalstringvar(--robot-metal)Bare machined parts: collars, bolts, tool bodies.
darkstringvar(--robot-dark)Cast joints, base, shadow side.
palettePartial<RobotPalette>Override any subset of roles at once, including glow and grid.

Source

src/components/ui/lidar-scan.tsx
"use client"

import type * as React from "react"

import {
  px,
  resolveRobotPalette,
  resolveRobotSize,
  robotSurface,
  type RobotPaletteProps,
  type RobotSize,
  type RobotVariant,
} from "@/lib/robocn/style"
import { cn } from "@/lib/utils"

export interface LidarSample {
  /** Clockwise degrees from the sensor's forward direction. */
  angle: number
  /** Nonnegative distance in the same units as maxRange. */
  distance: number
}

export interface LidarScanProps
  extends Omit<React.ComponentProps<"svg">, "color">, RobotPaletteProps {
  samples?: readonly LidarSample[]
  /** Range represented by the outer ring. Invalid or nonpositive values use 10. */
  maxRange?: number
  /** Clockwise sensor heading in degrees. Rotates returns and scan ray together. */
  heading?: number
  /** Controlled scan-ray angle relative to the sensor. Omit to hide the ray. */
  scanAngle?: number
  showRays?: boolean
  showRings?: boolean
  label?: string
  size?: RobotSize | number
  variant?: RobotVariant
}

function LidarScan({
  samples = [], maxRange = 10, heading = 0, scanAngle, showRays = false, showRings = true,
  label, size = "md", variant = "solid", color, accent, metal, dark, glow, grid,
  palette: paletteOverride, className, style, ...props
}: LidarScanProps) {
  const palette = resolveRobotPalette({ color, accent, metal, dark, glow, grid, palette: paletteOverride })
  const width = resolveRobotSize(size)
  const range = Number.isFinite(maxRange) && maxRange > 0 ? maxRange : 10
  const rotation = Number.isFinite(heading) ? ((heading % 360) + 360) % 360 : 0
  const points = samples.filter(sample => Number.isFinite(sample.angle) && Number.isFinite(sample.distance) && sample.distance >= 0 && sample.distance <= range)
    .map(sample => {
      const angle = (sample.angle % 360) * Math.PI / 180
      const radius = sample.distance / range * 72
      return { x: px(Math.sin(angle) * radius), y: px(-Math.cos(angle) * radius) }
    })
  const ray = scanAngle !== undefined && Number.isFinite(scanAngle) ? ((scanAngle % 360) + 360) % 360 : null

  return (
    <svg role="img" aria-label={`Lidar scan, ${points.length} returns, range ${range}`}
      viewBox="0 0 180 180" width={width} height={width}
      className={cn("max-w-full select-none", className)} style={{ color: palette.foreground, ...style }} {...props}>
      <g transform="translate(90 90)">
        <circle r={76} {...robotSurface("dark", variant, palette)} />
        {showRings && <g fill="none" stroke={palette.grid} strokeWidth={0.5} opacity={0.6}>
          {[18, 36, 54, 72].map(radius => <circle key={radius} r={radius} />)}
          <path d="M -72 0 h 144 M 0 -72 v 144" strokeDasharray="2 3" />
        </g>}
        {Array.from({ length: 36 }, (_, i) => <path key={i} d={`M 0 -${i % 3 === 0 ? 69 : 71} V -74`}
          transform={`rotate(${i * 10})`} stroke={palette.grid} strokeWidth={0.6} />)}
        <g data-scan transform={`rotate(${px(rotation)})`}>
          {ray !== null && <g transform={`rotate(${px(ray)})`}>
            <path d="M 0 0 L -18.63 -69.55 A 72 72 0 0 1 0 -72 Z" fill={palette.accent} opacity={0.12} />
            <path d="M 0 0 V -72" stroke={palette.accent} strokeWidth={1} />
          </g>}
          {showRays && <g stroke={palette.accent} strokeWidth={0.35} opacity={0.25}>
            {points.map((point, i) => <line key={i} data-ray x1={0} y1={0} x2={point.x} y2={point.y} />)}
          </g>}
          {points.map((point, i) => <circle key={i} data-return cx={point.x} cy={point.y} r={1.5} fill={palette.accent} />)}
          <circle r={5} {...robotSurface("shell", variant, palette)} />
          <path d="M -2 -1 L 0 -4 L 2 -1" fill="none" stroke={palette.foreground} strokeWidth={0.8} />
        </g>
      </g>
      <g fill={palette.foreground} fontFamily="ui-monospace, monospace" fontSize={4} textAnchor="middle">
        <text x={90} y={9}>0°</text><text x={173} y={91}>90°</text>
        <text x={7} y={91}>270°</text><text x={90} y={174}>{label ?? `RANGE ${range}`}</text>
      </g>
    </svg>
  )
}

export { LidarScan }