HabitatMap/AirCasting

View on GitHub
app/javascript/react/components/Map/Markers/createMarkerIcon.ts

Summary

Maintainability
C
1 day
Test Coverage
import { size } from "lodash";
import { gray400 } from "../../../assets/styles/colors";

const iconCache = new Map<string, google.maps.Icon>();

// Reuse a single canvas and context for text measurement
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;
context.font = "12px Roboto, Arial, sans-serif";
const textWidthCache = new Map<string, number>();

const getTextWidth = (text: string): number => {
  if (textWidthCache.has(text)) {
    return textWidthCache.get(text)!;
  }
  const width = context.measureText(text).width;
  textWidthCache.set(text, width);
  return width;
};

export const createMarkerIcon = (
  color: string,
  value: string,
  isSelected: boolean = false,
  shouldPulse: boolean
): google.maps.Icon => {
  // Round value to reduce number of unique icons
  const roundedValue = Math.round(Number(value.split(" ")[0]));
  const unit = value.split(" ")[1];
  const displayedValue = `${roundedValue} ${unit}`;

  const cacheKey = `${color}-${displayedValue}-${isSelected}-${shouldPulse}`;
  if (iconCache.has(cacheKey)) {
    return iconCache.get(cacheKey)!;
  }

  const padding = 7;
  const baseCircleX = 19;
  const baseCircleY = 20;
  const baseCircleR = 6;
  const rectHeight = 19;
  const height = 44;
  const strokeWidth = isSelected ? 1 : 0;
  const shadowRadius = isSelected ? 22 : 18;
  const maxScaleFactor = 1.6;
  const deltaR = shadowRadius * (maxScaleFactor - 1);

  const textWidth = getTextWidth(displayedValue);
  const mainContentWidth =
    8 + height / 2 + baseCircleR + padding + textWidth + 2;
  const totalWidth = Math.max(mainContentWidth + 8, shadowRadius * 2);

  const shadowColor = `${color}`;

  // Define viewBox based on whether pulsation is needed
  const viewBoxMinX = shouldPulse ? -deltaR : 0;
  const viewBoxMinY = shouldPulse ? -deltaR * 2 + padding : padding;

  const viewBoxWidth =
    totalWidth + (shouldPulse ? deltaR * 2 : 0) + padding * 2;
  const viewBoxHeight = height + (shouldPulse ? deltaR * 2 + padding : padding);

  const centerX = (totalWidth + padding * 2) / 2;
  const centerY = (height + padding * 2) / 2;

  // const centerX = baseCircleX + 2;
  // const centerY = baseCircleY;

  const svg = `
    <svg xmlns="http://www.w3.org/2000/svg" width="${viewBoxWidth}" height="${viewBoxHeight}" viewBox="${viewBoxMinX} ${viewBoxMinY} ${viewBoxWidth} ${viewBoxHeight}" overflow="visible">
      <defs>
        <!-- Radial Gradient for Shadow -->
        ${
          isSelected
            ? `<radialGradient id="shadowGradient" cx="50%" cy="50%" r="70%">
                <stop offset="0%" stop-color="${shadowColor}90" />
                <stop offset="40%" stop-color="${shadowColor}90" />
                <stop offset="100%" stop-color="${shadowColor}90" stop-opacity="0" />
              </radialGradient>`
            : `<radialGradient id="shadowGradient" cx="50%" cy="50%" r="60%">
                <stop offset="0%" stop-color="${shadowColor}95" />
                <stop offset="30%" stop-color="${shadowColor}95" />
                <stop offset="100%" stop-color="${shadowColor}95" />
              </radialGradient>`
        }
        <!-- Blur Filter -->
        ${
          !isSelected
            ? `<filter id="blur" x="-50%" y="-50%" width="200%" height="200%">
                <feGaussianBlur in="SourceGraphic" stdDeviation="3" />
              </filter>`
            : ""
        }
        <!-- Drop Shadow for Rectangle -->
        <filter id="dropShadow" x="-20%" y="-20%" width="140%" height="140%">
          <feGaussianBlur in="SourceAlpha" stdDeviation="0.5"/>
          <feOffset dx="1.25" dy="1.25" result="offsetblur"/>
          <feFlood flood-color="rgba(76, 86, 96, 0.1)"/>
          <feComposite in2="offsetblur" operator="in"/>
          <feMerge>
            <feMergeNode/>
            <feMergeNode in="SourceGraphic"/>
          </feMerge>
        </filter>
      </defs>
      <style>
        ${
          shouldPulse
            ? `
          @keyframes pulse-animation {
            0% { transform: scale(1); opacity: 1; }
            50% { transform: scale(${maxScaleFactor}); opacity: 0.9; }
            100% { transform: scale(1); opacity: 1; }
          }
          .pulse {
            animation: pulse-animation 2s infinite;
            transform-origin: ${
              centerX - mainContentWidth / 2 + 11
            }px ${centerY}px;
          }
        `
            : ""
        }
      </style>
      <!-- Pulsating Circle (Background) -->
      ${shouldPulse ? `<g class="pulse">` : ""}
              <circle cx="${
                centerX - mainContentWidth / 2 + 11
              }" cy="${centerY}" r="${shadowRadius}" fill="url(#shadowGradient)" ${
    !isSelected ? 'filter="url(#blur)"' : ""
  } />
            ${shouldPulse ? `</g>` : ""}
      <!-- Label Rectangle with Drop Shadow -->
      <rect x="${centerX - mainContentWidth / 2}" y="${
    centerY - rectHeight / 2
  }" rx="9" ry="${centerY}" width="${mainContentWidth}" height="${rectHeight}" fill="white" stroke="${color}" stroke-width="${strokeWidth}" filter="url(#dropShadow)"/>
      <!-- Small Colored Circle Inside Label -->
      <circle cx="${
        centerX - mainContentWidth / 2 + 11
      }" cy="${centerY}" r="${baseCircleR}" fill="${color}" stroke="${color}" stroke-width="${strokeWidth}" />
      <!-- Label Text -->
      <text x="${
        centerX - mainContentWidth / 2 + 11 + baseCircleR + padding
      }" y="${
    centerY + 5
  }" font-family="Roboto, Arial, sans-serif" font-size="12" font-weight="400" letter-spacing="0.14" fill="${gray400}" text-anchor="start">${displayedValue}</text>
    </svg>
  `;

  const selectedAnchor = new google.maps.Point(shadowRadius, shadowRadius);

  const icon = {
    url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`,
    anchor: isSelected ? selectedAnchor : null,
  };

  iconCache.set(cacheKey, icon);
  return icon;
};

export const createClusterIcon = (
  color: string,
  shouldPulse: boolean
): google.maps.Icon => {
  const cacheKey = `${color}-cluster-${shouldPulse}`;
  if (iconCache.has(cacheKey)) {
    return iconCache.get(cacheKey)!;
  }

  const baseSize = 30;
  const baseRadius = 14;
  const center = baseSize / 2;

  let totalSize = baseSize;
  let anchorPoint = new google.maps.Point(center, baseSize);
  let svgContent = "";
  let viewBox = `0 0 ${baseSize} ${baseSize}`;
  let pulseStyles = "";

  // Making sure the icon is not obscuring other map elements when not pulsing
  if (shouldPulse) {
    const maxScaleFactor = 2.0;
    const extraMargin = (baseSize * (maxScaleFactor - 1)) / 2;
    totalSize = baseSize + extraMargin * 2;
    const scaledBaseSize = baseSize * maxScaleFactor;
    const halfTotalSize = totalSize / 2;

    const offsetY = (scaledBaseSize - baseSize) / 2;
    const anchorY = halfTotalSize + offsetY;
    anchorPoint = new google.maps.Point(totalSize / 2, anchorY);
    viewBox = `0 0 ${totalSize} ${totalSize}`;

    pulseStyles = `
      .pulse {
        animation: pulse-animation 2s infinite;
        transform-origin: center;
        zIndex: 9999999999999999;
      }
      @keyframes pulse-animation {
        0% {
          transform: scale(1);
          opacity: 1;
        }
        50% {
          transform: scale(${maxScaleFactor});
          opacity: 0.8;
        }
        100% {
          transform: scale(1);
          opacity: 1;
        }
      }
    `;

    svgContent = `
      <g class="pulse">
        <circle stroke="${color}" fill="none" cx="${totalSize / 2}" cy="${
      totalSize / 2
    }" r="${baseRadius}"></circle>
        <g fill="${color}">
          <rect x="${(totalSize - 14) / 2}" y="${
      (totalSize - 14) / 2
    }" width="14" height="14" rx="7"></rect>
        </g>
      </g>
    `;
  } else {
    svgContent = `
      <circle stroke="${color}" fill="none" cx="${center}" cy="${center}" r="${baseRadius}"></circle>
      <g fill="${color}">
        <rect x="${(baseSize - 14) / 2}" y="${
      (baseSize - 14) / 2
    }" width="14" height="14" rx="7"></rect>
      </g>
    `;
  }

  const svg = `
    <svg xmlns="http://www.w3.org/2000/svg" width="${totalSize}" height="${totalSize}" viewBox="${viewBox}">
      <style>
        ${pulseStyles}
      </style>
      ${svgContent}
    </svg>
  `;

  const icon: google.maps.Icon = {
    url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`,
    scaledSize: new google.maps.Size(totalSize, totalSize),
    anchor: anchorPoint,
  };

  iconCache.set(cacheKey, icon);
  return icon;
};