Galaxy of Shape

Shape of galaxy? Galaxy of shape?

// 01·LIVE PREVIEW
Interactive stage
// 01.B·PARAMETERS
Shape
Light Mode
Generation
4,096 (12)
Spiral Winding (κ)
2
Rotation (ω)
0.18 rad/s
Polar Grid
Spiral Guides
Split Trails
Playing
Auto Evolve
// 02·USAGE
example.tsx
example.tsx
import { GalaxyOfShape } from "@/components/ui/galaxy-of-shape";

export default function Example() {
  return (
    <div className="w-full h-[520px]">
      <GalaxyOfShape
        shape="square"
        generation={12}
        lightMode="monochrome"
        spiralKappa={2}
        isPlaying
      />
    </div>
  );
}
// 03·API REFERENCE
14 props

Prop

Type

// 04·COMPONENT SOURCE
2 files
GalaxyOfShape.tsx
"use client";

import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import {
  buildGalaxyGenerationNodes,
  DEFAULT_GALAXY_CONFIG,
  evaluateLogSpiralAngle,
  evaluateShapeSize,
  type GalaxyLightMode,
  type GalaxyShapeNode,
  type GalaxyShapeType,
  THREE_ARM_BASE_ANGLES,
} from "./galaxy-shape-engine";

const DEFAULT_ZOOM = 1.0;
const MIN_ZOOM = 1.0;
const MAX_ZOOM = 12.0;

export interface GalaxyOfShapeProps {
  shape?: GalaxyShapeType;
  generation?: number;
  autoEvolve?: boolean;
  sizeDecay?: number;
  baseSize?: number;
  spiralKappa?: number;
  splitDistance?: number;
  splitAngleDeg?: number;
  angularVelocity?: number;
  lightMode?: GalaxyLightMode;
  showPolarGrid?: boolean;
  showSpiralGuides?: boolean;
  showSplitTrails?: boolean;
  isPlaying?: boolean;
  className?: string;
}

const STAR_UNIT_VERTICES: Array<{ x: number; y: number }> = (() => {
  const pts: Array<{ x: number; y: number }> = [];
  const spikes = 5;
  const outerR = 1.0;
  const innerR = 0.44;
  for (let i = 0; i < spikes * 2; i++) {
    const r = i % 2 === 0 ? outerR : innerR;
    const angle = (i * Math.PI) / spikes - Math.PI / 2;
    pts.push({ x: r * Math.cos(angle), y: r * Math.sin(angle) });
  }
  return pts;
})();

export function GalaxyOfShape({
  shape = "square",
  generation = DEFAULT_GALAXY_CONFIG.generation,
  autoEvolve = false,
  sizeDecay = DEFAULT_GALAXY_CONFIG.sizeDecay,
  baseSize = DEFAULT_GALAXY_CONFIG.baseSize,
  spiralKappa = DEFAULT_GALAXY_CONFIG.spiralKappa,
  splitDistance = DEFAULT_GALAXY_CONFIG.splitDistance,
  splitAngleDeg = DEFAULT_GALAXY_CONFIG.splitAngleDeg,
  angularVelocity = DEFAULT_GALAXY_CONFIG.angularVelocity,
  lightMode = "galaxy-light",
  showPolarGrid = false,
  showSpiralGuides = false,
  showSplitTrails = false,
  isPlaying = true,
  className = "",
}: GalaxyOfShapeProps) {
  const clampedGen = Math.max(0, Math.min(13, Math.round(generation)));

  const [isDragging, setIsDragging] = useState<boolean>(false);
  const zoomRef = useRef<number>(DEFAULT_ZOOM);
  const panRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
  const isDraggingRef = useRef<boolean>(false);
  const dragStartRef = useRef<{
    x: number;
    y: number;
    panX: number;
    panY: number;
  }>({
    x: 0,
    y: 0,
    panX: 0,
    panY: 0,
  });

  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const simTimeRef = useRef<number>(0);
  const splitAnimRef = useRef<number>(1.0);
  const evolveTimerRef = useRef<number>(0);
  const currentGenRef = useRef<number>(clampedGen);

  const shapeRef = useRef<GalaxyShapeType>(shape);
  shapeRef.current = shape;
  const isPlayingRef = useRef<boolean>(isPlaying);
  isPlayingRef.current = isPlaying;
  const autoEvolveRef = useRef<boolean>(autoEvolve);
  autoEvolveRef.current = autoEvolve;
  const sizeDecayRef = useRef<number>(sizeDecay);
  sizeDecayRef.current = sizeDecay;
  const baseSizeRef = useRef<number>(baseSize);
  baseSizeRef.current = baseSize;
  const spiralKappaRef = useRef<number>(spiralKappa);
  spiralKappaRef.current = spiralKappa;
  const splitDistanceRef = useRef<number>(splitDistance);
  splitDistanceRef.current = splitDistance;
  const splitAngleDegRef = useRef<number>(splitAngleDeg);
  splitAngleDegRef.current = splitAngleDeg;
  const angularVelocityRef = useRef<number>(angularVelocity);
  angularVelocityRef.current = angularVelocity;
  const lightModeRef = useRef<GalaxyLightMode>(lightMode);
  lightModeRef.current = lightMode;
  const showPolarGridRef = useRef<boolean>(showPolarGrid);
  showPolarGridRef.current = showPolarGrid;
  const showSpiralGuidesRef = useRef<boolean>(showSpiralGuides);
  showSpiralGuidesRef.current = showSpiralGuides;
  const showSplitTrailsRef = useRef<boolean>(showSplitTrails);
  showSplitTrailsRef.current = showSplitTrails;

  const galaxyNodes = useMemo<GalaxyShapeNode[]>(() => {
    return buildGalaxyGenerationNodes(
      currentGenRef.current,
      spiralKappa,
      splitDistance,
      splitAngleDeg,
    );
  }, [spiralKappa, splitDistance, splitAngleDeg]);

  const galaxyNodesRef = useRef<GalaxyShapeNode[]>(galaxyNodes);
  galaxyNodesRef.current = galaxyNodes;

  useEffect(() => {
    if (currentGenRef.current !== clampedGen) {
      currentGenRef.current = clampedGen;
      splitAnimRef.current = 0.0;
      galaxyNodesRef.current = buildGalaxyGenerationNodes(
        clampedGen,
        spiralKappaRef.current,
        splitDistanceRef.current,
        splitAngleDegRef.current,
      );
    }
  }, [clampedGen]);

  const resetZoomAndPan = useCallback(() => {
    zoomRef.current = DEFAULT_ZOOM;
    panRef.current = { x: 0, y: 0 };
  }, []);

  const handlePointerDown = useCallback(
    (e: React.PointerEvent<HTMLCanvasElement>) => {
      if (e.button !== 0) return;
      isDraggingRef.current = true;
      setIsDragging(true);
      dragStartRef.current = {
        x: e.clientX,
        y: e.clientY,
        panX: panRef.current.x,
        panY: panRef.current.y,
      };
      e.currentTarget.setPointerCapture(e.pointerId);
    },
    [],
  );

  const handlePointerMove = useCallback(
    (e: React.PointerEvent<HTMLCanvasElement>) => {
      if (!isDraggingRef.current) return;
      const dx = e.clientX - dragStartRef.current.x;
      const dy = e.clientY - dragStartRef.current.y;
      panRef.current = {
        x: dragStartRef.current.panX + dx,
        y: dragStartRef.current.panY + dy,
      };
    },
    [],
  );

  const handlePointerUp = useCallback(
    (e: React.PointerEvent<HTMLCanvasElement>) => {
      if (!isDraggingRef.current) return;
      isDraggingRef.current = false;
      setIsDragging(false);
      try {
        e.currentTarget.releasePointerCapture(e.pointerId);
      } catch {}
    },
    [],
  );

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let animId: number;
    let lastTime = performance.now();

    const handleResize = () => {
      if (!canvas) return;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const rect = canvas.getBoundingClientRect();
      const width = Math.max(320, rect.width || 800);
      const height = Math.max(320, rect.height || 640);
      if (
        canvas.width !== Math.round(width * dpr) ||
        canvas.height !== Math.round(height * dpr)
      ) {
        canvas.width = Math.round(width * dpr);
        canvas.height = Math.round(height * dpr);
      }
    };

    const handleWheel = (e: WheelEvent) => {
      e.preventDefault();
      const rect = canvas.getBoundingClientRect();
      const mouseX = e.clientX - rect.left;
      const mouseY = e.clientY - rect.top;
      const centerX = rect.width * 0.5;
      const centerY = rect.height * 0.5;

      const prevZoom = zoomRef.current;
      const zoomFactor = e.deltaY < 0 ? 1.12 : 0.89;
      const nextZoom = Math.min(
        MAX_ZOOM,
        Math.max(MIN_ZOOM, Number((prevZoom * zoomFactor).toFixed(2))),
      );
      if (nextZoom === prevZoom) return;

      if (nextZoom <= DEFAULT_ZOOM) {
        zoomRef.current = DEFAULT_ZOOM;
        panRef.current = { x: 0, y: 0 };
      } else {
        const scaleRatio = nextZoom / prevZoom;
        const relX = mouseX - (centerX + panRef.current.x);
        const relY = mouseY - (centerY + panRef.current.y);
        panRef.current = {
          x: mouseX - centerX - relX * scaleRatio,
          y: mouseY - centerY - relY * scaleRatio,
        };
        zoomRef.current = nextZoom;
      }
    };

    handleResize();
    const resizeObserver = new ResizeObserver(handleResize);
    resizeObserver.observe(canvas);
    window.addEventListener("resize", handleResize);
    canvas.addEventListener("wheel", handleWheel, { passive: false });

    const drawShapePrimitive = (
      context: CanvasRenderingContext2D,
      shapeType: GalaxyShapeType,
      px: number,
      py: number,
      size: number,
      rotation: number,
      fillColor: string,
      withBorder: boolean,
      alpha: number,
    ) => {
      context.globalAlpha = alpha;
      context.fillStyle = fillColor;

      if (shapeType === "square") {
        const half = size * 0.5;
        if (size <= 3.6 && !withBorder) {
          context.fillRect(px - half, py - half, size, size);
        } else {
          context.save();
          context.translate(px, py);
          context.rotate(rotation);
          context.fillRect(-half, -half, size, size);
          if (withBorder) {
            context.strokeStyle = "rgba(255, 255, 255, 0.72)";
            context.lineWidth = Math.max(0.75, Math.min(1.35, size * 0.1));
            context.strokeRect(-half, -half, size, size);
          }
          context.restore();
        }
      } else if (shapeType === "circle") {
        const radius = size * 0.5;
        context.beginPath();
        context.arc(px, py, radius, 0, Math.PI * 2);
        context.fill();
        if (withBorder) {
          context.strokeStyle = "rgba(255, 255, 255, 0.72)";
          context.lineWidth = Math.max(0.75, Math.min(1.35, size * 0.1));
          context.stroke();
        }
      } else {
        const outerR = size * 0.65;
        if (outerR <= 1.8 && !withBorder) {
          context.fillRect(
            px - outerR * 0.7,
            py - outerR * 0.7,
            outerR * 1.4,
            outerR * 1.4,
          );
        } else {
          context.save();
          context.translate(px, py);
          context.rotate(rotation);
          context.beginPath();
          for (let v = 0; v < STAR_UNIT_VERTICES.length; v++) {
            const vx = STAR_UNIT_VERTICES[v].x * outerR;
            const vy = STAR_UNIT_VERTICES[v].y * outerR;
            if (v === 0) context.moveTo(vx, vy);
            else context.lineTo(vx, vy);
          }
          context.closePath();
          context.fill();
          if (withBorder) {
            context.strokeStyle = "rgba(255, 255, 255, 0.72)";
            context.lineWidth = Math.max(0.7, Math.min(1.25, size * 0.08));
            context.stroke();
          }
          context.restore();
        }
      }
    };

    const renderFrame = (now: number) => {
      const dt = Math.min((now - lastTime) / 1000, 0.1);
      lastTime = now;

      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      const width = canvas.width / dpr;
      const height = canvas.height / dpr;

      if (isPlayingRef.current) {
        simTimeRef.current += dt;
        if (splitAnimRef.current < 1.0) {
          splitAnimRef.current = Math.min(
            1.0,
            splitAnimRef.current + dt * 1.85,
          );
        }

        if (autoEvolveRef.current) {
          evolveTimerRef.current += dt;
          const holdDuration = currentGenRef.current === 13 ? 3.6 : 1.65;
          if (evolveTimerRef.current >= holdDuration) {
            evolveTimerRef.current = 0;
            splitAnimRef.current = 0.0;
            const nextG =
              currentGenRef.current >= 13 ? 0 : currentGenRef.current + 1;
            currentGenRef.current = nextG;
            galaxyNodesRef.current = buildGalaxyGenerationNodes(
              nextG,
              spiralKappaRef.current,
              splitDistanceRef.current,
              splitAngleDegRef.current,
            );
          }
        }
      }

      const curZoom = zoomRef.current;
      const curPan = panRef.current;
      const cx = width * 0.5 + curPan.x;
      const cy = height * 0.5 + curPan.y;
      const maxRadiusPx = Math.min(width, height) * 0.44 * curZoom;

      const curGen = currentGenRef.current;
      const curShape = shapeRef.current;
      const curQ = sizeDecayRef.current;
      const curBaseSize = baseSizeRef.current;
      const curKappa = spiralKappaRef.current;
      const curOmega = angularVelocityRef.current;
      const curTime = simTimeRef.current;
      const globalRot = curOmega * curTime;
      const activeLight = lightModeRef.current;

      const rawTau = splitAnimRef.current;
      const tau = 1 - (1 - rawTau) ** 3;

      ctx.save();
      ctx.scale(dpr, dpr);

      const bgGrad = ctx.createRadialGradient(
        cx,
        cy,
        maxRadiusPx * 0.02,
        cx,
        cy,
        Math.max(width, height) * 0.72 * curZoom,
      );
      if (activeLight === "galaxy-light") {
        bgGrad.addColorStop(0.0, "#31276e");
        bgGrad.addColorStop(0.24, "#1b184a");
        bgGrad.addColorStop(0.58, "#0d1130");
        bgGrad.addColorStop(1.0, "#050614");
      } else if (activeLight === "monochrome") {
        bgGrad.addColorStop(0.0, "#18181b");
        bgGrad.addColorStop(0.5, "#09090b");
        bgGrad.addColorStop(1.0, "#020204");
      } else {
        bgGrad.addColorStop(0.0, "#241e56");
        bgGrad.addColorStop(0.38, "#16133d");
        bgGrad.addColorStop(0.75, "#0b0926");
        bgGrad.addColorStop(1.0, "#050414");
      }
      ctx.fillStyle = bgGrad;
      ctx.fillRect(0, 0, width, height);

      if (showPolarGridRef.current) {
        const gridOpacity = activeLight === "galaxy-light" ? 0.18 : 0.5;
        ctx.save();
        ctx.strokeStyle = `rgba(148, 163, 225, ${0.13 * gridOpacity})`;
        ctx.lineWidth = 0.8;

        const ringRadiiNorm = [0.085, 0.15, 0.23, 0.33, 0.45, 0.58, 0.74];
        for (let i = 0; i < ringRadiiNorm.length; i++) {
          const rPx = ringRadiiNorm[i] * maxRadiusPx;
          ctx.beginPath();
          ctx.arc(cx, cy, rPx, 0, Math.PI * 2);
          ctx.stroke();
        }

        const innerR = ringRadiiNorm[0] * maxRadiusPx;
        const outerR = maxRadiusPx * 1.15;
        ctx.strokeStyle = `rgba(148, 163, 225, ${0.12 * gridOpacity})`;
        for (let deg = 0; deg < 360; deg += 30) {
          const rad = (deg * Math.PI) / 180;
          ctx.beginPath();
          ctx.moveTo(cx + innerR * Math.cos(rad), cy + innerR * Math.sin(rad));
          ctx.lineTo(cx + outerR * Math.cos(rad), cy + outerR * Math.sin(rad));
          ctx.stroke();
        }
        ctx.restore();
      }

      if (showSpiralGuidesRef.current && curGen >= 2) {
        const guideAlpha = activeLight === "galaxy-light" ? 0.16 : 0.36;
        const r0 = 0.044;
        const discEdgeR = 0.78;
        ctx.save();
        ctx.strokeStyle = `rgba(196, 181, 253, ${guideAlpha})`;
        ctx.lineWidth = 1.2;

        for (let arm = 0; arm < 3; arm++) {
          const armTheta0 = THREE_ARM_BASE_ANGLES[arm] + globalRot;
          ctx.beginPath();
          const steps = 150;
          for (let s = 0; s <= steps; s++) {
            const rNorm = r0 + (s / steps) * (discEdgeR - r0);
            const theta = evaluateLogSpiralAngle(
              rNorm,
              r0,
              armTheta0,
              curKappa,
            );
            const sx = cx + rNorm * maxRadiusPx * Math.cos(theta);
            const sy = cy + rNorm * maxRadiusPx * Math.sin(theta);
            if (s === 0) ctx.moveTo(sx, sy);
            else ctx.lineTo(sx, sy);
          }
          ctx.stroke();
        }
        ctx.restore();
      }

      if (activeLight === "galaxy-light") {
        ctx.save();
        ctx.globalCompositeOperation = "lighter";

        const r0 = 0.044;
        for (let arm = 0; arm < 3; arm++) {
          const armTheta0 = THREE_ARM_BASE_ANGLES[arm] + globalRot;
          const puffSteps = 28;
          for (let s = 1; s <= puffSteps; s++) {
            const frac = s / puffSteps;
            const rNorm = 0.065 + frac * 0.695;
            const theta = evaluateLogSpiralAngle(
              rNorm,
              r0,
              armTheta0,
              curKappa,
            );
            const px = cx + rNorm * maxRadiusPx * Math.cos(theta);
            const py = cy + rNorm * maxRadiusPx * Math.sin(theta);
            const puffRadius = maxRadiusPx * (0.072 + frac * 0.105);
            const edgeFade = (1.0 - frac) ** 1.3;
            const puffAlpha = 0.046 * edgeFade;

            const armGlow = ctx.createRadialGradient(
              px,
              py,
              0,
              px,
              py,
              puffRadius,
            );
            armGlow.addColorStop(0.0, `rgba(180, 195, 255, ${puffAlpha})`);
            armGlow.addColorStop(
              0.5,
              `rgba(129, 140, 248, ${puffAlpha * 0.45})`,
            );
            armGlow.addColorStop(1.0, "rgba(30, 27, 75, 0.0)");
            ctx.fillStyle = armGlow;
            ctx.beginPath();
            ctx.arc(px, py, puffRadius, 0, Math.PI * 2);
            ctx.fill();
          }
        }

        const coreGlow = ctx.createRadialGradient(
          cx,
          cy,
          0,
          cx,
          cy,
          maxRadiusPx * 0.46,
        );
        coreGlow.addColorStop(0.0, "rgba(255, 255, 255, 0.96)");
        coreGlow.addColorStop(0.055, "rgba(255, 255, 255, 0.85)");
        coreGlow.addColorStop(0.11, "rgba(224, 231, 255, 0.52)");
        coreGlow.addColorStop(0.24, "rgba(165, 180, 252, 0.22)");
        coreGlow.addColorStop(0.52, "rgba(109, 40, 217, 0.07)");
        coreGlow.addColorStop(1.0, "rgba(15, 23, 42, 0.0)");
        ctx.fillStyle = coreGlow;
        ctx.beginPath();
        ctx.arc(cx, cy, maxRadiusPx * 0.46, 0, Math.PI * 2);
        ctx.fill();

        ctx.restore();
      }

      const nodes = galaxyNodesRef.current;
      const cosG = Math.cos(globalRot);
      const sinG = Math.sin(globalRot);

      if (showSplitTrailsRef.current && curGen >= 1 && curGen <= 8) {
        ctx.save();
        const trailAlpha = Math.max(0.08, 0.28 - curGen * 0.022);
        ctx.strokeStyle = `rgba(196, 181, 253, ${trailAlpha})`;
        ctx.lineWidth = 0.85;

        for (let i = 0; i < nodes.length; i++) {
          const n = nodes[i];
          const px0 = n.parentX * cosG - n.parentY * sinG;
          const py0 = n.parentX * sinG + n.parentY * cosG;

          const ix = n.parentX + (n.x - n.parentX) * tau;
          const iy = n.parentY + (n.y - n.parentY) * tau;
          const px1 = ix * cosG - iy * sinG;
          const py1 = iy * cosG + ix * sinG;

          const sx0 = cx + px0 * maxRadiusPx;
          const sy0 = cy + py0 * maxRadiusPx;
          const sx1 = cx + px1 * maxRadiusPx;
          const sy1 = cy + py1 * maxRadiusPx;

          const midX = (sx0 + sx1) * 0.5 - (sy1 - sy0) * 0.18 * n.lastSign;
          const midY = (sy0 + sy1) * 0.5 + (sx1 - sx0) * 0.18 * n.lastSign;

          ctx.beginPath();
          ctx.moveTo(sx0, sy0);
          ctx.quadraticCurveTo(midX, midY, sx1, sy1);
          ctx.stroke();
        }
        ctx.restore();
      }

      const effectiveGen = Math.max(0, curGen - 1 + tau);
      const shapeSizePx =
        evaluateShapeSize(curBaseSize, curQ, effectiveGen) * curZoom;
      const withCrispBorder = activeLight === "palette" && curGen <= 6;

      ctx.save();
      if (activeLight === "galaxy-light") {
        ctx.globalCompositeOperation = "lighter";
      }

      if (curGen <= 5 && activeLight === "palette") {
        ctx.shadowBlur = 8;
        ctx.shadowColor = "rgba(255, 255, 255, 0.35)";
      }

      for (let i = 0; i < nodes.length; i++) {
        const n = nodes[i];
        const localX = n.parentX + (n.x - n.parentX) * tau;
        const localY = n.parentY + (n.y - n.parentY) * tau;

        const rx = localX * cosG - localY * sinG;
        const ry = localX * sinG + localY * cosG;

        const screenX = cx + rx * maxRadiusPx;
        const screenY = cy + ry * maxRadiusPx;

        let color = n.paletteColor;
        let alpha = 0.95;
        let drawSize = shapeSizePx;

        if (activeLight === "galaxy-light") {
          color = n.galaxyColor;
          alpha = Math.min(1.0, 0.32 + n.luminosity * 0.68);
          drawSize = shapeSizePx * (0.72 + n.luminosity * 0.78);

          if (n.luminosity > 0.72 && curGen >= 10) {
            const haloSize = drawSize * 2.2;
            ctx.globalAlpha = alpha * 0.24;
            ctx.fillStyle = "#a5b4fc";
            ctx.fillRect(
              screenX - haloSize * 0.5,
              screenY - haloSize * 0.5,
              haloSize,
              haloSize,
            );
          }
        } else if (activeLight === "monochrome") {
          color = "#f8fafc";
          alpha = 0.45 + n.luminosity * 0.55;
          drawSize = shapeSizePx * (0.75 + n.luminosity * 0.65);
        }

        drawShapePrimitive(
          ctx,
          curShape,
          screenX,
          screenY,
          drawSize,
          n.localRotation + globalRot,
          color,
          withCrispBorder,
          alpha,
        );
      }
      ctx.restore();

      ctx.restore();
      animId = requestAnimationFrame(renderFrame);
    };

    animId = requestAnimationFrame(renderFrame);

    return () => {
      cancelAnimationFrame(animId);
      resizeObserver.disconnect();
      window.removeEventListener("resize", handleResize);
      canvas.removeEventListener("wheel", handleWheel);
    };
  }, []);

  return (
    <div
      className={cn(
        "relative w-full h-full min-h-[380px] overflow-hidden select-none bg-neutral-950",
        className,
      )}
    >
      <canvas
        ref={canvasRef}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerCancel={handlePointerUp}
        onDoubleClick={resetZoomAndPan}
        className={`w-full h-full block touch-none ${
          isDragging ? "cursor-grabbing" : "cursor-grab"
        }`}
        title="Scroll to zoom, drag to pan, double-click to reset view"
      />
    </div>
  );
}

export default GalaxyOfShape;
IHSOR Discrete Fourier Transform 124-Harmonics Epicycle Synthesis Wordmark
@ 2024-2026 ihsor.dev