<template>
  <div class="svg-content">
    <div :class="['loading-screen', { loaded: loadedPercent === 100 }]">
      <div :style="{ width: `${loadedPercent}%` }" class="loading-bar"></div>
    </div>
    <div class="content" ref="root">
      <div class="video-scrolls">
        <VideoScroll
          v-for="scroll in scrolls"
          :definition="scroll"
          :key="scroll.id"
        />
      </div>
    </div>
    <AudioArea
      v-for="audio in audioAreas"
      :definition="audio"
      :bbox="bbox"
      :key="audio.id"
    />
    <div class="dev devpanel">
      <div>
        <span>Current viewport position:</span>
        <span>{{ Math.round(bbox.x) }}x{{ Math.round(bbox.y) }}</span>
      </div>
      <div>
        <span>Current cursor position:</span>
        <span
          >{{ Math.round(mousePosition.x) }}x{{
            Math.round(mousePosition.y)
          }}</span
        >
      </div>
      <div>
        <span>Zoom level:</span
        ><span>{{ Math.round(bbox.z * 1000) / 1000 }}</span>
      </div>
      <label>
        <input v-model="showInternal" type="checkbox" />
        <label>Show internal elements</label>
      </label>
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, onMounted, reactive, ref } from "vue";
import createPanZoom, { PanZoom } from "panzoom";
import VideoScroll, {
  FileDef,
  VideoScrollDef,
  VideoScrollDirection,
} from "@/components/VideoScroll.vue";
import AudioArea, { AudioAreaDef } from "@/components/AudioArea.vue";
import Stats from "stats.js";
import { rotate } from "@/utils";
import fetchProgress from "fetch-progress";
import { throttle } from "lodash";

export interface BoundingBox {
  x: number;
  y: number;
  w: number;
  h: number;
  z: number;
}

export default defineComponent({
  name: "SVGContent",
  components: { AudioArea, VideoScroll },
  props: {
    url: {
      type: String,
      required: true,
    },
  },
  data() {
    return {
      showInternal: false,
    };
  },
  watch: {
    showInternal(value) {
      Array.from(this.root!.getElementsByClassName("internal")).forEach(
        (el) => {
          (el as SVGElement).style.visibility = value ? "visible" : "hidden";
        }
      );
    },
  },
  setup(props, { emit }) {
    const root = ref<HTMLDivElement | null>(null);
    const loadedPercent = ref(0);
    const panzoom = ref<null | PanZoom>(null);
    const anchors = ref<SVGRectElement[]>([]);
    const scrolls = ref<VideoScrollDef[]>([]);
    const panToAnchor = ref();
    const audioAreas = ref<AudioAreaDef[]>([]);
    const bbox: BoundingBox = reactive({
      x: ref(0),
      y: ref(0),
      w: ref(0),
      h: ref(0),
      z: ref(1),
    });
    const mousePosition = reactive({
      x: ref(0),
      y: ref(0),
    });
    const panning = ref(false);

    onMounted(async () => {
      const element = (root.value as unknown) as HTMLDivElement;
      console.info("[SVG] Initializing.");

      // Fetch & load SVG
      console.info(`[SVG] Fetching "${props.url}..."`);
      const fetchResult = await fetch(props.url).then(
        fetchProgress({
          onProgress(progress) {
            loadedPercent.value = (progress as any).percentage;
          },
        })
      );
      const svgParsed = new DOMParser().parseFromString(
        await fetchResult.text(),
        "image/svg+xml"
      ) as Document;
      console.debug("[SVG] Loaded.");
      loadedPercent.value = 100;
      const svg = element.appendChild(
        svgParsed.firstElementChild as Element
      ) as any;

      // Set document background
      const pageColor = svg
        .getElementById("base")
        ?.attributes.getNamedItem("pagecolor");
      if (pageColor) {
        console.debug(`[SVG] Found pageColor attribute: ${pageColor.value}`);
        emit("setBackground", pageColor.value);
      }

      // PanZoom
      const pz = createPanZoom(element, {
        smoothScroll: false,
        minZoom: 0.05,
        maxZoom: 3637937,
        zoomSpeed: 0.05,
        zoomDoubleClickSpeed: 1,
        beforeMouseDown: () => {
          return panning.value;
        },
        beforeWheel: () => {
          return panning.value;
        },
        onDoubleClick: () => {
          if (!document.fullscreenElement) {
            console.debug("[SVG] Fullscreen requested.");
            document.body.requestFullscreen();
          } else {
            console.debug("[SVG] Fullscreen exited.");
            document.exitFullscreen();
          }

          return true;
        },
      });
      panzoom.value = pz;

      // Create throttled function for hash updates
      const updateLocationHash = throttle(
        (bbox: BoundingBox, scale: number) => {
          window.location.hash = `${Math.round(
            bbox.x + bbox.w / 2
          )},${Math.round(bbox.y + bbox.h / 2)},${Math.round(scale * 1000) /
            1000}z`;
        },
        250
      );

      // Calculate SVG-unit bounding box, update transform
      pz.on("transform", function(_) {
        const transform = pz.getTransform();
        const currentRatio =
          (svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;

        bbox.x = (transform.x / currentRatio) * -1;
        bbox.y = (transform.y / currentRatio) * -1;
        bbox.w = window.innerWidth / currentRatio;
        bbox.h = window.innerHeight / currentRatio;
        bbox.z = transform.scale;

        // Use the throttled function instead of updating directly
        updateLocationHash(bbox, transform.scale);
      });

      function panToElement(target: SVGRectElement, smooth: boolean) {
        console.debug(`[SVG] Panning to element: #${target.id}`);
        const transform = pz.getTransform();
        const currentRatio =
          (svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;
        const ratio = svg.clientWidth / svg.viewBox.baseVal.width;
        const targetScale =
          window.innerWidth / (target.width.baseVal.value * ratio);

        const svgTargetX =
          (target.x.baseVal.value + target.width.baseVal.value / 2) *
          currentRatio;
        const svgTargetY =
          (target.y.baseVal.value + target.height.baseVal.value / 2) *
          currentRatio;

        if (smooth) {
          panning.value = true;

          pz.smoothMoveTo(
            svgTargetX * -1 + window.innerWidth / 2,
            svgTargetY * -1 + window.innerHeight / 2
          );

          setTimeout(() => {
            const finalTransform = pz.getTransform();
            pz.smoothZoomAbs(
              svgTargetX + finalTransform.x,
              svgTargetY + finalTransform.y,
              targetScale
            );
            setTimeout(() => {
              panning.value = false;
            }, 400);
          }, 400 * 4);
        } else {
          pz.moveTo(
            svgTargetX * -1 + window.innerWidth / 2,
            svgTargetY * -1 + window.innerHeight / 2
          );
          pz.zoomAbs(
            window.innerWidth / 2,
            window.innerHeight / 2,
            targetScale
          );
        }
      }

      panToAnchor.value = (anchor: SVGRectElement) => {
        panToElement(anchor, true);
      };

      // Process start element
      const start = processStart(svg);
      if (start) {
        console.info("[SVG] Found start element.");
        window.addEventListener("keydown", (ev) => {
          if (ev.key === " ") {
            panToElement(start, true);
          }
        });
      }

      // Pan to start element or location in hash
      const locationMatch = window.location.href.match(
        /#([\-0-9.]+),([\-0-9.]+),([0-9.]+)z/
      );
      if (locationMatch) {
        console.debug(`[SVGCONTENT] Got a location match: ${locationMatch}`);
        const [_, x, y, z] = locationMatch;

        const transform = pz.getTransform();
        const currentRatio =
          (svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;
        pz.moveTo(
          parseFloat(x) * currentRatio * -1 + window.innerWidth / 2,
          parseFloat(y) * currentRatio * -1 + window.innerHeight / 2
        );
        pz.zoomAbs(
          window.innerWidth / 2,
          window.innerHeight / 2,
          parseFloat(z)
        );
      } else if (start) {
        console.debug(`[SVGCONTENT] Panning to start anchor.`);
        panToElement(start, false);
      }

      // Anchors
      console.debug("[SVG] Processing anchors.");
      anchors.value = processAnchors(svg);
      console.info(`[SVG] Found ${anchors.value.length} anchors.`);

      // Links
      console.debug("[SVG] Processing hyperlinks.");
      const { anchor, hyper } = processHyperlinks(svg);
      console.info(
        `[SVG] Found ${anchor.length} anchor links and ${hyper.length} hyperlinks.`
      );
      anchor.forEach(([anchorId, element]) => {
        const anchor = anchors.value.find((a) => a.id == anchorId);
        if (!anchor) {
          console.error(`[SVG] Could not find anchor #${anchorId}!`);
          return;
        }
        element.addEventListener("click", () => {
          panToElement(anchor, true);
        });
      });

      // Audio areas
      console.debug("[SVG] Processing audio areas.");
      audioAreas.value = processAudio(svg);
      console.info(`[SVG] Found ${audioAreas.value.length} audio areas.`);

      // Videoscrolls
      console.debug("[SVG] Processing video scrolls.");
      scrolls.value = await processScrolls(svg);
      console.info(`[SVG] Found ${scrolls.value.length} video scrolls.`);

      // Debug Stats
      let stats: Stats | undefined;
      if (window.location.search.includes("debug")) {
        console.info("[SVG] DEBUG mode active, turning on stats & dev panel.");
        stats = new Stats();
        document.body.appendChild(stats.dom);

        Array.from(document.body.getElementsByClassName("dev")).forEach(
          (el) => {
            (el as HTMLElement).style.display = "block";
          }
        );
      }

      // Animations: FPS Counter, Edge scrolling
      let mouse: MouseEvent | undefined;
      window.addEventListener("mousemove", (ev) => {
        mouse = ev;
        const transform = pz.getTransform();
        const currentRatio =
          (svg.clientWidth * transform.scale) / svg.viewBox.baseVal.width;
        mousePosition.x = (mouse.clientX - transform.x) / currentRatio;
        mousePosition.y = (mouse.clientY - transform.y) / currentRatio;
      });

      let gamePadZoomSpeed = 10;

      function animate() {
        if (stats) {
          stats.begin();
        }

        // Edge scrolling
        const MOVE_EDGE_X = window.innerWidth * 0.25;
        const MOVE_EDGE_Y = window.innerHeight * 0.25;
        const MAX_SPEED = 20;

        if (mouse && !panning.value && document.fullscreenElement) {
          let horizontalShift: number;
          let verticalShift: number;

          const transform = pz.getTransform();
          if (
            mouse.clientX < MOVE_EDGE_X ||
            mouse.clientX > window.innerWidth - MOVE_EDGE_X
          ) {
            const horizontalEdgeDistance =
              mouse.clientX < window.innerWidth / 2
                ? mouse.clientX
                : mouse.clientX - window.innerWidth;
            const horizontalRatio =
              (MOVE_EDGE_X - Math.abs(horizontalEdgeDistance)) / MOVE_EDGE_X;
            const direction = mouse.clientX < MOVE_EDGE_X ? 1 : -1;
            horizontalShift = horizontalRatio * direction * MAX_SPEED;
          } else {
            horizontalShift = 0;
          }

          if (
            mouse.clientY < MOVE_EDGE_Y ||
            mouse.clientY > window.innerHeight - MOVE_EDGE_Y
          ) {
            const verticalEdgeDistance =
              mouse.clientY < window.innerHeight / 2
                ? mouse.clientY
                : mouse.clientY - window.innerHeight;
            const verticalRatio =
              (MOVE_EDGE_Y - Math.abs(verticalEdgeDistance)) / MOVE_EDGE_Y;
            const direction = mouse.clientY < MOVE_EDGE_Y ? 1 : -1;
            verticalShift = verticalRatio * direction * MAX_SPEED;
          } else {
            verticalShift = 0;
          }

          if (horizontalShift || verticalShift) {
            pz.moveTo(
              transform!.x + horizontalShift,
              transform!.y + verticalShift
            );
          }
        }

        if (navigator.getGamepads) {
          var gamepads = navigator.getGamepads();
          var gp = gamepads[0];

          if (gp) {
            if (gp.buttons[7].pressed) {
              gamePadZoomSpeed += 0.1;
            }
            if (gp.buttons[5].pressed) {
              gamePadZoomSpeed -= 0.1;
            }
            if (gamePadZoomSpeed < 1) {
              gamePadZoomSpeed = 1;
            }
            if (gamePadZoomSpeed > 30) {
              gamePadZoomSpeed = 30;
            }

            const transform = pz.getTransform();

            const horizontalShift = gp.axes[0] * -1 * gamePadZoomSpeed;
            const verticalShift = gp.axes[1] * -1 * gamePadZoomSpeed;

            if (horizontalShift || verticalShift) {
              pz.moveTo(
                transform!.x + horizontalShift,
                transform!.y + verticalShift
              );
            }
          }
        }

        if (stats) {
          stats.end();
        }

        requestAnimationFrame(animate);
      }

      requestAnimationFrame(animate);
    });

    return {
      root,
      loadedPercent,
      panzoom,
      anchors,
      panToAnchor,
      scrolls,
      audioAreas,
      bbox,
      mousePosition,
    };
  },
});

function processAnchors(document: XMLDocument): SVGRectElement[] {
  const result: SVGRectElement[] = [];
  Array.from(document.getElementsByTagName("rect"))
    .filter((el) => el.id.startsWith("anchor"))
    .forEach((anchor) => {
      console.debug(`[SVG/ANCHORS] Found anchor #${anchor.id}.`);
      anchor.classList.add("internal");
      result.push(anchor);
    });
  return result;
}

async function processScrolls(svg: XMLDocument): Promise<VideoScrollDef[]> {
  const ratio = (svg as any).clientWidth / (svg as any).viewBox.baseVal.width;

  return Promise.all(
    Array.from(svg.getElementsByTagName("image"))
      .map((el) => {
        el.removeAttribute("xlink:href");
        return el;
      })
      .filter((el) =>
        Array.from(el.children).some((el) => el.tagName == "desc")
      )
      .map(async (el) => {
        const descNode = Array.from(el.children).find(
          (el) => el.tagName == "desc"
        );
        console.debug(
          `[SVG/VIDEOSCROLLS] Found video scroll #${el.id}: ${descNode?.textContent}`
        );
        const [directionString, filesURL] = descNode!.textContent!.split("\n");

        const directions: VideoScrollDirection[] = directionString
          .split(" ")
          .map((direction) => {
            if (
              !Object.values(VideoScrollDirection).includes(
                direction as VideoScrollDirection
              )
            ) {
              console.error(
                `Unknown direction definition: "${direction}" (in #${el.id})`
              );
              return false;
            }
            return direction as VideoScrollDirection;
          })
          .filter((d) => Boolean(d)) as VideoScrollDirection[];

        console.debug(`[SVG/VIDEOSCROLLS] Fetching ${filesURL}...`);
        const fileFetch = await fetch(`content/${filesURL}`);
        const preURL = fileFetch.url.replace(/\/files.lst$/, "");
        const fileListContent = await fileFetch.text();
        const files: FileDef[] = fileListContent
          .split("\n")
          .filter(Boolean)
          .map((str) => {
            const [filename, blurhash] = str.split("\t") as [
              string,
              string | undefined
            ];
            return { src: `${preURL}/${filename}`, blurhash };
          });

        let x = el.x.baseVal.value;
        let y = el.y.baseVal.value;
        let w = el.width.baseVal.value;
        let h = el.height.baseVal.value;
        let angle = 0;

        const transform = el.attributes.getNamedItem("transform");
        const rotateResult = /rotate\((-?[0-9.]+)\)/.exec(
          transform?.value || ""
        );
        if (rotateResult) {
          angle = parseFloat(rotateResult[1]);
          const [ncx, ncy] = rotate(x + w / 2, y + h / 2, 0, 0, angle);
          x = ncx - w / 2;
          y = ncy - h / 2;
        }

        return {
          id: el.id,
          top: y * ratio,
          left: x * ratio,
          angle,
          width: w * ratio,
          height: h * ratio,
          directions,
          files,
        };
      })
  );
}

function processAudio(svg: XMLDocument): AudioAreaDef[] {
  const circles: (SVGCircleElement | SVGEllipseElement)[] = Array.from(
    svg.getElementsByTagName("circle")
  );
  const ellipses: (SVGCircleElement | SVGEllipseElement)[] = Array.from(
    svg.getElementsByTagName("ellipse")
  );
  return circles
    .concat(ellipses)
    .filter((el) => Array.from(el.children).some((el) => el.tagName == "desc"))
    .map((el) => {
      const descNode = Array.from(el.children).find(
        (el) => el.tagName == "desc"
      );
      console.debug(
        `[SVG/AUDIOAREAS] Found audio area #${el.id}: ${descNode?.textContent}`
      );
      const audioSrc = descNode!.textContent!.trim();

      const radius = el.hasAttribute("r")
        ? (el as SVGCircleElement).r.baseVal.value
        : ((el as SVGEllipseElement).rx.baseVal.value +
            (el as SVGEllipseElement).ry.baseVal.value) /
          2;

      el.classList.add("internal");

      return {
        id: el.id,
        cx: el.cx.baseVal.value,
        cy: el.cy.baseVal.value,
        radius,
        src: `content/${audioSrc}`,
      };
    });
}

function processHyperlinks(
  svg: XMLDocument
): { anchor: [string, SVGAElement][]; hyper: SVGAElement[] } {
  const anchor: [string, SVGAElement][] = [];
  const hyper: SVGAElement[] = [];
  Array.from(svg.getElementsByTagName("a")).forEach((el) => {
    if (el.getAttribute("xlink:href")?.startsWith("anchor")) {
      anchor.push([
        el.getAttribute("xlink:href") as string,
        (el as unknown) as SVGAElement,
      ]);
      el.setAttribute("xlink:href", "#");
    } else {
      el.setAttribute("target", "_blank");
      hyper.push((el as unknown) as SVGAElement);
    }
  });
  return { anchor, hyper };
}

function processStart(svg: XMLDocument): SVGRectElement | null {
  const start = svg.getElementById("start");
  if (start) {
    start.classList.add("internal");
  }
  return start as SVGRectElement | null;
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style>
.svg-content svg {
  overflow: visible;
}

.svg-content svg .internal {
  visibility: hidden;
}

.loading-screen {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background: black;
  transition: opacity 0.5s;
  opacity: 1;
}

.loading-screen.loaded {
  opacity: 0 !important;
}

.loading-bar {
  height: 6px;
  background: white;
  margin: calc(50vh - 3px) auto;
  transition: width 0.2s;
}

.dev {
  display: none;
}

.devpanel {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 999;

  color: white;
  background: #000000aa;
  border: 2px solid white;
  font-family: monospace;
  padding: 1em 2em;
}

.devpanel div {
  display: flex;
  justify-content: space-between;
}

.devpanel label {
  float: right;
}

.devpanel div span {
  margin: 0 0.5em;
}
</style>
