Mini Map Panel

Viewport overview panel showing canvas bounds, markers, and the current zoom window.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/mini-map-panel.json
bash

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

import { forwardRef } from "react";

import { cn } from "../../lib/utils";

export type MiniMapMarker = {
  id: string;
  label?: string;
  x: number;
  y: number;
};

export type MiniMapPanelProps = React.ComponentPropsWithoutRef<"div"> & {
  markers?: MiniMapMarker[];
  title?: string;
  viewport: {
    height: number;
    width: number;
    x: number;
    y: number;
    zoom: number;
  };
  world: {
    height: number;
    width: number;
  };
};

const MiniMapPanel = forwardRef<HTMLDivElement, MiniMapPanelProps>(
  (
    { className, markers = [], title = "Overview", viewport, world, ...props },
    ref,
  ) => {
    const viewportWidth = Math.max(
      (viewport.width / viewport.zoom / world.width) * 100,
      8,
    );
    const viewportHeight = Math.max(
      (viewport.height / viewport.zoom / world.height) * 100,
      8,
    );
    const viewportLeft = Math.min(
      Math.max((viewport.x / world.width) * 100, 0),
      100 - viewportWidth,
    );
    const viewportTop = Math.min(
      Math.max((viewport.y / world.height) * 100, 0),
      100 - viewportHeight,
    );

    return (
      <div
        className={cn(
          "w-52 rounded-sm border border-border bg-background p-3 font-mono",
          className,
        )}
        ref={ref}
        {...props}
      >
        <div className="mb-3 flex items-center justify-between">
          <div>
            <div className="text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground">
              {title}
            </div>
            <div className="mt-1 text-xs text-muted-foreground">
              Zoom {Math.round(viewport.zoom * 100)}%
            </div>
          </div>
        </div>
        <div className="relative aspect-[4/3] overflow-hidden rounded-sm border border-border bg-background">
          {markers.map((marker) => (
            <div
              className="absolute size-1.5 -translate-x-1/2 -translate-y-1/2 bg-foreground"
              key={marker.id}
              style={{
                left: `${(marker.x / world.width) * 100}%`,
                top: `${(marker.y / world.height) * 100}%`,
              }}
              title={marker.label}
            />
          ))}
          <div
            className="absolute border border-foreground/80 bg-transparent"
            style={{
              height: `${viewportHeight}%`,
              left: `${viewportLeft}%`,
              top: `${viewportTop}%`,
              width: `${viewportWidth}%`,
            }}
          />
        </div>
      </div>
    );
  },
);

MiniMapPanel.displayName = "MiniMapPanel";

export { MiniMapPanel };
typescript

Dependencies

  • @vllnt/ui@^0.2.1