Skip to content

Commit

Permalink
feat(maps): add search feature; insert maps as svg
Browse files Browse the repository at this point in the history
  • Loading branch information
ArtemSBulgakov committed Oct 28, 2024
1 parent 478cc24 commit 8d8d846
Show file tree
Hide file tree
Showing 6 changed files with 194 additions and 60 deletions.
22 changes: 22 additions & 0 deletions src/api/maps/map-image.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
import { customFetch } from "@/api/helpers/custom-fetch.ts";
import { useQuery } from "@tanstack/react-query";

export function getMapImageUrl(svgName: string) {
return `${import.meta.env.VITE_MAPS_API_URL}/static/${svgName}`;
}

export function useMapImage(svgName: string) {
const url = getMapImageUrl(svgName);
return useQuery({
queryKey: ["maps-image", url],
queryFn: async ({ signal }) => {
if (!url) {
return undefined;
}

// Use client to add the auth token
return customFetch.GET(url, {
parseAs: "text",
signal,
});
},
enabled: !!url,
});
}
5 changes: 3 additions & 2 deletions src/api/maps/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,9 @@ export interface components {
SearchResult: {
/** Scene Id */
scene_id: string;
/** Matching Area Indexes */
matching_area_indexes: number[];
area: components["schemas"]["Area"];
/** Area Index */
area_index: number;
};
/** ValidationError */
ValidationError: {
Expand Down
9 changes: 6 additions & 3 deletions src/app/routes/_with_menu/maps.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { Topbar } from "@/components/layout/Topbar.tsx";
import { MapsPage } from "@/components/maps/MapsPage.tsx";
import { MapsPageTabs } from "@/components/maps/MapsPageTabs.tsx";
import { createFileRoute } from "@tanstack/react-router";
import { Helmet } from "react-helmet-async";

export const Route = createFileRoute("/_with_menu/maps")({
validateSearch: (
search: Record<string, unknown>,
): { sceneId: string | undefined } => {
): { sceneId?: string; q?: string } => {
return {
sceneId: (search.sceneId as string) ?? undefined,
q: (search.q as string) ?? undefined,
};
},

component: function RouteComponent() {
const { sceneId } = Route.useSearch();
const { sceneId, q } = Route.useSearch();
return (
<div className="flex min-h-full flex-col overflow-y-auto @container/content">
<Helmet>
Expand All @@ -25,7 +27,8 @@ export const Route = createFileRoute("/_with_menu/maps")({
</Helmet>

<Topbar title="Maps" />
<MapsPage sceneId={sceneId} />
<MapsPageTabs />
<MapsPage sceneId={sceneId} q={q} />
</div>
);
},
Expand Down
61 changes: 44 additions & 17 deletions src/components/maps/MapView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mapsTypes } from "@/api/maps";
import { getMapImageUrl } from "@/api/maps/map-image.ts";
import { useMapImage } from "@/api/maps/map-image.ts";
import { FloatingOverlay, FloatingPortal } from "@floating-ui/react";
import {
memo,
Expand All @@ -10,7 +10,13 @@ import {
useState,
} from "react";

export function MapView({ scene }: { scene: mapsTypes.SchemaScene }) {
export function MapView({
scene,
highlightAreas,
}: {
scene: mapsTypes.SchemaScene;
highlightAreas: mapsTypes.SchemaSearchResult[];
}) {
const [fullscreen, setFullscreen] = useState(false);
const switchFullscreen = useCallback(() => setFullscreen((v) => !v), []);

Expand All @@ -20,7 +26,9 @@ export function MapView({ scene }: { scene: mapsTypes.SchemaScene }) {
if (fullscreen) {
document.body.requestFullscreen?.();
} else {
document.exitFullscreen?.();
if (document.fullscreenElement) {
document.exitFullscreen?.();
}
}
}, [fullscreen]);

Expand Down Expand Up @@ -52,8 +60,8 @@ export function MapView({ scene }: { scene: mapsTypes.SchemaScene }) {

return (
<FullscreenMode enable={fullscreen}>
<div className="relative h-full">
<MapViewer scene={scene} />
<div className="relative h-full w-full">
<MapViewer scene={scene} highlightAreas={highlightAreas} />
<button
className="absolute bottom-2 right-2 flex h-fit rounded-xl bg-primary-main/50 px-2 py-2 hover:bg-primary-main/75"
onClick={() => switchFullscreen()}
Expand Down Expand Up @@ -82,22 +90,26 @@ function FullscreenMode({

const MapViewer = memo(function MapViewer({
scene,
highlightAreas,
}: {
scene: mapsTypes.SchemaScene;
highlightAreas: mapsTypes.SchemaSearchResult[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const imageRef = useRef<HTMLDivElement>(null);
const options = useRef({
offsetX: 0,
offsetY: 0,
zoom: 1,
});

const { data: mapSvg } = useMapImage(scene.svg_file);

const updateImage = () => {
if (!containerRef.current || !imageRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const imageWidth = imageRef.current.width * options.current.zoom;
const imageHeight = imageRef.current.height * options.current.zoom;
const imageWidth = imageRef.current.clientWidth * options.current.zoom;
const imageHeight = imageRef.current.clientHeight * options.current.zoom;

options.current.offsetX = Math.max(
Math.min(options.current.offsetX, rect.width * 0.8),
Expand All @@ -117,8 +129,8 @@ const MapViewer = memo(function MapViewer({
if (!containerRef.current || !imageRef.current) return;
// Set initial offset to center the image
const rect = containerRef.current.getBoundingClientRect();
const imageWidth = imageRef.current.width;
const imageHeight = imageRef.current.height;
const imageWidth = imageRef.current.clientWidth;
const imageHeight = imageRef.current.clientHeight;
options.current.offsetX = (rect.width - imageWidth) / 2;
options.current.offsetY = (rect.height - imageHeight) / 2;
updateImage();
Expand Down Expand Up @@ -267,14 +279,29 @@ const MapViewer = memo(function MapViewer({
return (
<div
ref={containerRef}
className="h-full cursor-grab overflow-hidden rounded-xl bg-gray-50"
className="h-full w-full cursor-grab overflow-hidden rounded-xl bg-gray-50"
>
<img
ref={imageRef}
src={getMapImageUrl(scene.svg_file)}
alt={scene.title}
draggable={false}
/>
<style type="text/css">
{highlightAreas?.length
? `
@keyframes pulse {
0%, 100% { opacity: 0.2; }
50% { opacity: 0.5; }
}
${highlightAreas.map((s) => `[id="${s.area.svg_polygon_id}"]`).join(",")} {
fill: violet !important;
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
`
: ""}
</style>
{mapSvg?.data && (
<div
ref={imageRef}
dangerouslySetInnerHTML={{ __html: mapSvg.data }}
className="block h-full w-full overflow-hidden [&>svg]:h-full [&>svg]:w-full"
/>
)}
</div>
);
});
89 changes: 51 additions & 38 deletions src/components/maps/MapsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,65 @@
import { $maps } from "@/api/maps";
import { MapView } from "@/components/maps/MapView.tsx";
import { Link } from "@tanstack/react-router";
import clsx from "clsx";
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";

export function MapsPage({ sceneId }: { sceneId: string | undefined }) {
export function MapsPage({
sceneId,
q,
}: {
sceneId: string | undefined;
q: string | undefined;
}) {
const navigate = useNavigate();
const { data: scenes } = $maps.useQuery("get", "/scenes/");
const { data: searchResult } = $maps.useQuery(
"get",
"/scenes/areas/search",
{
params: { query: { query: q ?? "" } },
},
{
enabled: q !== undefined,
},
);

useEffect(() => {
// Show correct floor by navigating to URL with first sceneId
const firstSceneId = searchResult?.[0]?.scene_id;
if (firstSceneId !== undefined && firstSceneId !== sceneId) {
navigate({
to: "/maps",
search: { sceneId: firstSceneId, q: q },
});
}
}, [searchResult, sceneId, q, navigate]);

const currentScene =
scenes?.find((scene) => scene.scene_id === sceneId) ?? scenes?.[0];

if (!currentScene) {
// Loading scenes or some error...
return <></>;
}

return (
<div className="flex grow flex-col">
<div className="flex flex-row flex-wrap gap-2 p-4">
{scenes?.map((scene) => (
<Link
key={scene.scene_id}
to="/maps"
search={{ sceneId: scene.scene_id }}
className={clsx(
"rounded-xl bg-primary-main px-4 py-2 text-lg font-semibold hover:bg-primary-hover",
scene.scene_id === sceneId ? "bg-primary-hover" : "",
)}
>
{scene.title}
</Link>
))}
<div className="mt-1 flex grow flex-col gap-4 @3xl/content:flex-row">
<div className="min-h-[600px] grow">
<MapView scene={currentScene} highlightAreas={searchResult ?? []} />
</div>

{currentScene && (
<div className="flex flex-row flex-wrap gap-4 2xl:flex-nowrap">
<div className="">
<MapView scene={currentScene} />
</div>
<div className="flex min-w-56 flex-col gap-2 px-2">
<h3 className="text-2xl font-semibold">Legend:</h3>
{currentScene.legend?.map((legendEntry) => (
<div key={legendEntry.legend_id} className="flex flex-row gap-2">
<div
className="mt-1.5 h-4 w-4 flex-shrink-0 rounded-full"
style={{ backgroundColor: legendEntry.color ?? undefined }}
/>
<span className="whitespace-pre-wrap">
{legendEntry.legend}
</span>
</div>
))}
<div className="flex w-full shrink-0 flex-col gap-2 px-2 @3xl/content:w-64">
<h3 className="text-2xl font-semibold">Legend:</h3>
{currentScene.legend?.map((legendEntry) => (
<div key={legendEntry.legend_id} className="flex flex-row gap-2">
<div
className="mt-1.5 h-4 w-4 flex-shrink-0 rounded-full"
style={{ backgroundColor: legendEntry.color ?? undefined }}
/>
<span className="whitespace-pre-wrap">{legendEntry.legend}</span>
</div>
</div>
)}
))}
</div>
</div>
);
}
68 changes: 68 additions & 0 deletions src/components/maps/MapsPageTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { $maps } from "@/api/maps";
import { Link, useLocation, useNavigate } from "@tanstack/react-router";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";

export function MapsPageTabs() {
const navigate = useNavigate();
const { data: scenes } = $maps.useQuery("get", "/scenes/");
const { sceneId, q } = useLocation({ select: ({ search }) => search });
const [searchText, setSearchText] = useState(q ?? "");
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
// Show current search query
setSearchText(q ?? "");
}, [q]);

const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
inputRef.current?.focus();
// Set search query in URL
if (searchText) {
navigate({ to: "/maps", search: { q: searchText, sceneId } });
}
};

return (
<div className="flex shrink-0 flex-col whitespace-nowrap @3xl/content:flex-row">
<form
onSubmit={onSubmit}
className="flex items-center border-b-[1px] border-b-secondary-hover px-2 pb-[1px] focus-within:border-b-2 focus-within:border-b-focus focus-within:pb-0"
>
<input
ref={inputRef}
placeholder="Search any place"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="min-w-0 grow bg-transparent px-2 py-1 outline-none"
/>
<button
type="submit"
tabIndex={-1} // Do not allow to focus on this button
className="icon-[material-symbols--search-rounded] shrink-0 text-2xl text-secondary-hover"
/>
</form>

<div className="flex grow flex-row overflow-x-auto whitespace-nowrap">
<div className="w-2 shrink-0 border-b-[1px] border-b-secondary-hover @3xl/content:w-1" />
{scenes?.map((scene) => (
<Link
key={scene.scene_id}
to="/maps"
search={{ sceneId: scene.scene_id }}
className={clsx(
"px-2 py-1",
scene.scene_id === sceneId
? "border-b-2 border-b-focus"
: "border-b-[1px] border-b-secondary-hover",
)}
>
{scene.title}
</Link>
))}
<div className="min-w-2 grow border-b-[1px] border-b-secondary-hover" />
</div>
</div>
);
}

0 comments on commit 8d8d846

Please sign in to comment.