diff --git a/public/images/uvTexture1.png b/public/images/uvTexture1.png new file mode 100644 index 00000000..ffbb2a73 Binary files /dev/null and b/public/images/uvTexture1.png differ diff --git a/public/images/uvTexture1.thumb.png b/public/images/uvTexture1.thumb.png new file mode 100644 index 00000000..62043d2c Binary files /dev/null and b/public/images/uvTexture1.thumb.png differ diff --git a/public/images/uvTexture2.png b/public/images/uvTexture2.png new file mode 100644 index 00000000..d903b948 Binary files /dev/null and b/public/images/uvTexture2.png differ diff --git a/public/images/uvTexture2.thumb.png b/public/images/uvTexture2.thumb.png new file mode 100644 index 00000000..71be4c87 Binary files /dev/null and b/public/images/uvTexture2.thumb.png differ diff --git a/public/images/uvTexture3.png b/public/images/uvTexture3.png new file mode 100644 index 00000000..abe564b1 Binary files /dev/null and b/public/images/uvTexture3.png differ diff --git a/public/images/uvTexture3.thumb.png b/public/images/uvTexture3.thumb.png new file mode 100644 index 00000000..14b52c5d Binary files /dev/null and b/public/images/uvTexture3.thumb.png differ diff --git a/public/images/uvTexture4.png b/public/images/uvTexture4.png new file mode 100644 index 00000000..8427d95c Binary files /dev/null and b/public/images/uvTexture4.png differ diff --git a/public/images/uvTexture4.thumb.png b/public/images/uvTexture4.thumb.png new file mode 100644 index 00000000..09bac3b6 Binary files /dev/null and b/public/images/uvTexture4.thumb.png differ diff --git a/public/images/uvTexture5.png b/public/images/uvTexture5.png new file mode 100644 index 00000000..96a9ff53 Binary files /dev/null and b/public/images/uvTexture5.png differ diff --git a/public/images/uvTexture5.thumb.png b/public/images/uvTexture5.thumb.png new file mode 100644 index 00000000..d85f2078 Binary files /dev/null and b/public/images/uvTexture5.thumb.png differ diff --git a/src/app.tsx b/src/app.tsx index 047df5c1..583ff38a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -73,7 +73,7 @@ const THEMES: AppThemes = { mainAttibuteItemColor: color_brand_primary, mainAttributeHighlightColor: hilite_canvas_primary, mainHistogramColor: color_brand_secondary, - bookmarkFileInteracrions: dim_canvas_primary, + bookmarkFileInteractions: dim_canvas_primary, validateTileOk: color_brand_secondary_dark, validateTileWarning: color_accent_tertiary, filtrationImage: color_brand_quaternary, diff --git a/src/components/bookmarks-panel/bookmarks-panel.spec.tsx b/src/components/bookmarks-panel/bookmarks-panel.spec.tsx index f71d73ae..d893a6b4 100644 --- a/src/components/bookmarks-panel/bookmarks-panel.spec.tsx +++ b/src/components/bookmarks-panel/bookmarks-panel.spec.tsx @@ -3,10 +3,11 @@ import { PageId } from "../../types"; import { useAppLayout } from "../../utils/hooks/layout"; import { renderWithTheme } from "../../utils/testing-utils/render-with-theme"; import { BookmarksPanel } from "./bookmarks-panel"; -import { dragAndDropText } from "./upload-panel"; jest.mock("../../utils/hooks/layout"); +const dragAndDropText = "Drag and drop your json file here"; + const TEST_BOOKMARKS = [ { id: "testId1", diff --git a/src/components/bookmarks-panel/bookmarks-panel.tsx b/src/components/bookmarks-panel/bookmarks-panel.tsx index 54727eaf..1166bfb8 100644 --- a/src/components/bookmarks-panel/bookmarks-panel.tsx +++ b/src/components/bookmarks-panel/bookmarks-panel.tsx @@ -15,7 +15,7 @@ import ConfirmationIcon from "../../../public/icons/confirmation.svg"; import CloseIcon from "../../../public/icons/close.svg"; import ConfirmIcon from "../../../public/icons/confirmation.svg"; import { BookmarkOptionsMenu } from "./bookmark-option-menu"; -import { UploadPanel } from "./upload-panel"; +import { UploadPanel } from "../upload-panel/upload-panel"; import { UnsavedBookmarkWarning } from "./unsaved-bookmark-warning"; import { Popover } from "react-tiny-popover"; import { color_brand_tertiary } from "../../constants/colors"; @@ -27,6 +27,9 @@ import { useAppLayout, } from "../../utils/hooks/layout"; +import { FileType, FileUploaded } from "../../types"; +import { parseBookmarks } from "../../utils/bookmarks-utils"; + enum PopoverType { options, upload, @@ -55,13 +58,13 @@ const Container = styled.div` tablet: "0", mobile: "0", })}; - - bottom: ${getCurrentLayoutProperty({ + + bottom: ${getCurrentLayoutProperty({ desktop: "24px;", tablet: "0", mobile: "0", })}; - + width: ${getCurrentLayoutProperty({ desktop: "60%", tablet: "100%", @@ -238,9 +241,12 @@ export const BookmarksPanel = ({ setPopoverType(PopoverType.none); }; - const onBookmarksUploadedHandler = (bookmarks) => { + const onBookmarksUploadedHandler = async ({ fileContent }: FileUploaded) => { setPopoverType(PopoverType.none); - onBookmarksUploaded(bookmarks); + if (typeof fileContent === "string") { + const bookmarksParsed = await parseBookmarks(fileContent); + bookmarksParsed && onBookmarksUploaded(bookmarksParsed); + } }; const renderPopoverContent = () => { @@ -256,8 +262,11 @@ export const BookmarksPanel = ({ if (popoverType === PopoverType.upload) { return ( setPopoverType(PopoverType.none)} - onBookmarksUploaded={onBookmarksUploadedHandler} + onFileUploaded={onBookmarksUploadedHandler} /> ); } diff --git a/src/components/bookmarks-panel/unsaved-bookmark-warning.tsx b/src/components/bookmarks-panel/unsaved-bookmark-warning.tsx index 5b0a3cfb..49abbb67 100644 --- a/src/components/bookmarks-panel/unsaved-bookmark-warning.tsx +++ b/src/components/bookmarks-panel/unsaved-bookmark-warning.tsx @@ -1,5 +1,5 @@ import styled from "styled-components"; -import { UploadPanelItem } from "./upload-panel-item"; +import { UploadPanelItem } from "../upload-panel/upload-panel-item"; const Continer = styled.div` box-sizing: border-box; diff --git a/src/components/bookmarks-panel/upload-panel.spec.tsx b/src/components/bookmarks-panel/upload-panel.spec.tsx deleted file mode 100644 index a6eb361b..00000000 --- a/src/components/bookmarks-panel/upload-panel.spec.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { createEvent, fireEvent, waitFor } from "@testing-library/react"; -import { screen } from "@testing-library/react"; -import { renderWithTheme } from "../../utils/testing-utils/render-with-theme"; -import { UploadPanel, dragAndDropText } from "./upload-panel"; - -jest.mock('@hyperjump/json-schema', () => ({ - add: jest.fn(), - get: jest.fn(), - validate: jest.fn().mockImplementation(() => Promise.resolve( - jest.fn().mockImplementation(() => ({ - valid: true - })) - )) -})); - -const onCancel = jest.fn(); -const onBookmarksUploaded = jest.fn(); - -const callRender = (renderFunc, props = {}) => { - return renderFunc( - - ); -}; - -describe("UploadPanel", () => { - // Stub FileReader and simulate onload function call - // @ts-expect-error - rewrite FileReader Class - global.FileReader = class { - constructor() { - setTimeout(async () => { - await this.onload({ - target: { - result: '{"test":true}' - } - }); // simulate success - }, 0); - } - - onload = (data) => data; - - readAsText = jest.fn(); - } - - it("Should render upload panel", () => { - const { container, getByText } = callRender(renderWithTheme); - const fileInteractionContainer = container.firstChild.firstChild; - expect(container.firstChild).toBeInTheDocument(); - getByText(dragAndDropText); - getByText("or"); - getByText("browse file"); - expect(fileInteractionContainer.childNodes.length).toBe(4); - }); - - it("Should upload file", async () => { - const file = new File(["(⌐□_□)"], "test.json", { type: "application/json" }); - - callRender(renderWithTheme); - - const label = screen.getByTestId('upload-file-label'); - - await waitFor(() => - fireEvent.dragEnter(label) - ); - - const overlay = screen.getByTestId('dnd-overlay'); - - const fileDropEvent = createEvent.drop(overlay); - - // Stub dataTransfer object for file drop event - Object.defineProperty(fileDropEvent, 'dataTransfer', { - value: { - files: [file] - }, - }); - - await waitFor(() => - fireEvent(overlay, fileDropEvent) - ); - - expect(onBookmarksUploaded).toHaveBeenCalled(); - }); -}); diff --git a/src/components/debug-panel/debug-panel.tsx b/src/components/debug-panel/debug-panel.tsx index ef6ca86e..30aa5677 100644 --- a/src/components/debug-panel/debug-panel.tsx +++ b/src/components/debug-panel/debug-panel.tsx @@ -1,5 +1,15 @@ import { ReactEventHandler } from "react"; import styled from "styled-components"; +import { useState } from "react"; +import { addIconItem } from "../../redux/slices/icon-list-slice"; +import { IIconItem, IconListSetName } from "../../types"; +import md5 from "md5"; +import { IconListPanel } from "../icon-list-panel/icon-list-panel"; +import { ActionIconButton } from "../action-icon-button/action-icon-button"; +import PlusIcon from "../../../public/icons/plus.svg"; +import { ButtonSize } from "../../types"; +import { UploadPanel } from "../upload-panel/upload-panel"; +import { FileType, FileUploaded } from "../../types"; import { BoundingVolumeColoredBy, @@ -25,6 +35,8 @@ import { selectDebugOptions, } from "../../redux/slices/debug-options-slice"; +export const TEXTURE_ICON_SIZE = 54; + const CloseButtonWrapper = styled.div` position: absolute; right: 6px; @@ -56,6 +68,22 @@ const RadioButtonWrapper = styled.div` margin: 0 16px; `; +const TextureControlPanel = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + align-items: start; + margin: 0px 16px 0px 16px; +`; + +const UploadPanelContainer = styled.div` + position: absolute; + top: 24px; + // Make upload panel centered related to debug panel. + // 168px is half upload panel width. + left: calc(50% - 168px); +`; + type DebugPanelProps = { onClose: ReactEventHandler; }; @@ -63,8 +91,35 @@ type DebugPanelProps = { export const DebugPanel = ({ onClose }: DebugPanelProps) => { const layout = useAppLayout(); const dispatch = useAppDispatch(); + const [showFileUploadPanel, setShowFileUploadPanel] = useState(false); const debugOptions = useAppSelector(selectDebugOptions); + const onTextureInsertClick = () => { + setShowFileUploadPanel(true); + }; + + const onFileUploadedHandler = async ({ fileContent, info }: FileUploaded) => { + setShowFileUploadPanel(false); + const url = info.url as string; + const hash = md5(url); + const blob = new Blob([fileContent as ArrayBuffer]); + const objectURL = URL.createObjectURL(blob); + + const texture: IIconItem = { + id: `${hash}`, + icon: objectURL, + extData: { imageUrl: fileContent }, + custom: true, + }; + dispatch( + addIconItem({ + iconListSetName: IconListSetName.uvDebugTexture, + iconItem: texture, + setCurrent: true, + }) + ); + }; + return ( @@ -159,6 +214,37 @@ export const DebugPanel = ({ onClose }: DebugPanelProps) => { } /> + {debugOptions.showUVDebugTexture && ( + + + + Insert Texture + + + )} + + {showFileUploadPanel && ( + + { + setShowFileUploadPanel(false); + }} + onFileUploaded={onFileUploadedHandler} + /> + + )} + Color diff --git a/src/components/deck-gl-wrapper/deck-gl-wrapper.spec.tsx b/src/components/deck-gl-wrapper/deck-gl-wrapper.spec.tsx index a7c095b4..ce435141 100644 --- a/src/components/deck-gl-wrapper/deck-gl-wrapper.spec.tsx +++ b/src/components/deck-gl-wrapper/deck-gl-wrapper.spec.tsx @@ -37,7 +37,6 @@ import { MapController } from "@deck.gl/core"; import { TerrainLayer, Tile3DLayer } from "@deck.gl/geo-layers"; import { load } from "@loaders.gl/core"; import { LineLayer, ScatterplotLayer } from "@deck.gl/layers"; -import { ImageLoader } from "@loaders.gl/images"; import { Tileset3D } from "@loaders.gl/tiles"; import { BoundingVolumeLayer, CustomTile3DLayer } from "../../layers"; import { COORDINATE_SYSTEM, I3SLoader } from "@loaders.gl/i3s"; @@ -184,14 +183,10 @@ describe("Deck.gl I3S map component", () => { expect(controller2).toEqual(controllerExpected); }); - it("Should load UV debug texture", () => { + it("Should load UV debug texture", async () => { const { rerender } = callRender(renderWithProvider, { loadDebugTextureImage: true, }); - expect(load).toHaveBeenCalledWith( - "https://raw.githubusercontent.com/visgl/deck.gl-data/master/images/uv-debug-texture.jpg", - ImageLoader - ); expect(load).toHaveBeenCalledTimes(1); callRender(rerender); expect(load).toHaveBeenCalledTimes(1); diff --git a/src/components/deck-gl-wrapper/deck-gl-wrapper.tsx b/src/components/deck-gl-wrapper/deck-gl-wrapper.tsx index 49c58abe..d2600aca 100644 --- a/src/components/deck-gl-wrapper/deck-gl-wrapper.tsx +++ b/src/components/deck-gl-wrapper/deck-gl-wrapper.tsx @@ -41,13 +41,15 @@ import { } from "../../utils/debug/normals-utils"; import { getLonLatWithElevationOffset } from "../../utils/elevation-utils"; -import { useAppDispatch, useAppSelector } from "../../redux/hooks"; +import { useAppSelector, useAppDispatch } from "../../redux/hooks"; import { selectColorsByAttribute } from "../../redux/slices/symbolization-slice"; import { selectDragMode } from "../../redux/slices/drag-mode-slice"; +import { selectIconItemPicked } from "../../redux/slices/icon-list-slice"; import { fetchUVDebugTexture, selectUVDebugTexture, } from "../../redux/slices/uv-debug-texture-slice"; +import { IconListSetName } from "../../types"; import { selectMiniMap, selectMiniMapViewPort, @@ -290,11 +292,17 @@ export const DeckGlWrapper = ({ }, }); const [terrainTiles, setTerrainTiles] = useState({}); - const uvDebugTexture = useAppSelector(selectUVDebugTexture); + const iconItemPicked = useAppSelector( + selectIconItemPicked(IconListSetName.uvDebugTexture) + ); + const imageUrl = (iconItemPicked?.extData?.imageUrl as string) || ""; + const uvDebugTexture = useAppSelector(selectUVDebugTexture(imageUrl)); const uvDebugTextureRef = useRef(null); uvDebugTextureRef.current = uvDebugTexture; const [needTransitionToTileset, setNeedTransitionToTileset] = useState(false); + const [forceRefresh, setForceRefresh] = useState(false); + const showDebugTextureRef = useRef(false); showDebugTextureRef.current = showDebugTexture; @@ -304,12 +312,11 @@ export const DeckGlWrapper = ({ const dispatch = useAppDispatch(); - /** Load debug texture if necessary */ useEffect(() => { - if (loadDebugTextureImage && !uvDebugTexture) { - dispatch(fetchUVDebugTexture()); + if (loadDebugTextureImage && imageUrl) { + dispatch(fetchUVDebugTexture(imageUrl)); } - }, [loadDebugTextureImage]); + }, [imageUrl]); /** * Hook to call multiple changing function based on selected tileset. @@ -345,14 +352,19 @@ export const DeckGlWrapper = ({ }, [loadTiles]); useEffect(() => { + let c = 0; loadedTilesets.forEach(async (tileset) => { if (showDebugTexture) { await selectDebugTextureForTileset(tileset, uvDebugTexture); } else { selectOriginalTextureForTileset(); } + c++; + if (c === loadedTilesets.length) { + setForceRefresh(!forceRefresh); + } }); - }, [showDebugTexture]); + }, [showDebugTexture, uvDebugTexture]); const getViewState = () => parentViewState || (showMinimap && viewState) || { main: viewState.main }; diff --git a/src/components/icon-list-panel/icon-list-panel.tsx b/src/components/icon-list-panel/icon-list-panel.tsx new file mode 100644 index 00000000..0c39e148 --- /dev/null +++ b/src/components/icon-list-panel/icon-list-panel.tsx @@ -0,0 +1,87 @@ +import styled, { css } from "styled-components"; +import { useAppDispatch, useAppSelector } from "../../redux/hooks"; + +import { + setIconItemPicked, + selectIconList, + selectIconItemPickedId, +} from "../../redux/slices/icon-list-slice"; + +import { IconListSetName } from "../../types"; + +const TexturePanel = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: start; + align-items: center; + border-width: 0; + border-radius: 8px; + gap: 8px; + margin-top: 10px; + margin-left: 10px; +`; + +const TextureIcon = styled.div<{ + icon: string; + size: number; + active?: boolean; +}>` + display: flex; + position: relative; + height: ${({ size }) => `${size}px`}; + width: ${({ size }) => `${size}px`}; + margin: 0; + background-image: ${({ icon }) => `url(${icon})`}; + background-size: cover; + background-repeat: no-repeat; + cursor: pointer; + border-width: 0; + border-radius: 8px; + + ${({ active = false }) => + active && + css` + box-shadow: 0 0 0 2px #000000, 0 0 0 4px #ffffff; + `} +`; + +type IconListPanelProps = { + iconListSetName: IconListSetName; + group?: string; + iconSize: number; +}; + +export const IconListPanel = ({ + iconListSetName, + group, + iconSize, +}: IconListPanelProps) => { + const dispatch = useAppDispatch(); + + const imageArray = useAppSelector(selectIconList(iconListSetName, group)); + const imagePickedKey = useAppSelector( + selectIconItemPickedId(iconListSetName) + ); + + return ( + + {imageArray.map((item) => ( + { + dispatch( + setIconItemPicked({ + iconListSetName: iconListSetName, + id: item.id, + }) + ); + }} + /> + ))} + + ); +}; diff --git a/src/components/bookmarks-panel/upload-panel-item.spec.tsx b/src/components/upload-panel/upload-panel-item.spec.tsx similarity index 95% rename from src/components/bookmarks-panel/upload-panel-item.spec.tsx rename to src/components/upload-panel/upload-panel-item.spec.tsx index f04cb91a..fef15093 100644 --- a/src/components/bookmarks-panel/upload-panel-item.spec.tsx +++ b/src/components/upload-panel/upload-panel-item.spec.tsx @@ -43,11 +43,7 @@ describe("UploadPanelItem - Cancel and Confirm buttons", () => { describe("UploadPanelItem - Cancel only button", () => { const callRender = (renderFunc, props = {}) => { return renderFunc( - + Test text ); diff --git a/src/components/bookmarks-panel/upload-panel-item.tsx b/src/components/upload-panel/upload-panel-item.tsx similarity index 99% rename from src/components/bookmarks-panel/upload-panel-item.tsx rename to src/components/upload-panel/upload-panel-item.tsx index 1ed83b22..5a2f938f 100644 --- a/src/components/bookmarks-panel/upload-panel-item.tsx +++ b/src/components/upload-panel/upload-panel-item.tsx @@ -5,7 +5,7 @@ import WarningIcon from "../../../public/icons/warning.svg"; import { useAppLayout } from "../../utils/hooks/layout"; const Container = styled.div` - width: 335px; + width: 295px; height: 329px; padding: 16px; border-radius: 8px; diff --git a/src/components/upload-panel/upload-panel.spec.tsx b/src/components/upload-panel/upload-panel.spec.tsx new file mode 100644 index 00000000..ee3e8716 --- /dev/null +++ b/src/components/upload-panel/upload-panel.spec.tsx @@ -0,0 +1,121 @@ +import { createEvent, fireEvent, waitFor } from "@testing-library/react"; +import { screen } from "@testing-library/react"; +import { renderWithTheme } from "../../utils/testing-utils/render-with-theme"; +import { UploadPanel } from "../upload-panel/upload-panel"; +import { FileType } from "../../types"; + +jest.mock("@hyperjump/json-schema", () => ({ + add: jest.fn(), + get: jest.fn(), + validate: jest.fn().mockImplementation(() => + Promise.resolve( + jest.fn().mockImplementation(() => ({ + valid: true, + })) + ) + ), +})); + +const onCancel = jest.fn(); +const onFileUploaded = jest.fn(); + +const dragAndDropText = "Drag and drop your texture file here"; + +const callRender = (renderFunc, props = { fileType: FileType.text }) => { + return renderFunc( + + ); +}; + +describe("UploadPanel", () => { + // Stub FileReader and simulate onload function call + // @ts-expect-error - rewrite FileReader Class + global.FileReader = class { + constructor() { + setTimeout(async () => { + await this.onload({ + target: { + result: '{"test":true}', + }, + }); // simulate success + }, 0); + } + + onload = (data) => data; + + readAsText = jest.fn(); + readAsArrayBuffer = jest.fn(); + }; + + it("Should render upload panel", () => { + const { container, getByText } = callRender(renderWithTheme, { + fileType: FileType.text, + }); + const fileInteractionContainer = container.firstChild.firstChild; + expect(container.firstChild).toBeInTheDocument(); + getByText(dragAndDropText); + getByText("or"); + getByText("browse file"); + expect(fileInteractionContainer.childNodes.length).toBe(4); + }); + + it("Should upload text file", async () => { + const file = new File(["(⌐□_□)"], "test.json", { + type: "application/json", + }); + + callRender(renderWithTheme, { fileType: FileType.text }); + + const label = screen.getByTestId("upload-file-label"); + + await waitFor(() => fireEvent.dragEnter(label)); + + const overlay = screen.getByTestId("dnd-overlay"); + + const fileDropEvent = createEvent.drop(overlay); + + // Stub dataTransfer object for file drop event + Object.defineProperty(fileDropEvent, "dataTransfer", { + value: { + files: [file], + }, + }); + + await waitFor(() => fireEvent(overlay, fileDropEvent)); + + expect(onFileUploaded).toHaveBeenCalled(); + }); + + it("Should upload binary file", async () => { + const file = new File(["(⌐□_□)"], "test.json", { + type: "application/json", + }); + + callRender(renderWithTheme, { fileType: FileType.binary }); + + const label = screen.getByTestId("upload-file-label"); + + await waitFor(() => fireEvent.dragEnter(label)); + + const overlay = screen.getByTestId("dnd-overlay"); + + const fileDropEvent = createEvent.drop(overlay); + + // Stub dataTransfer object for file drop event + Object.defineProperty(fileDropEvent, "dataTransfer", { + value: { + files: [file], + }, + }); + + await waitFor(() => fireEvent(overlay, fileDropEvent)); + + expect(onFileUploaded).toHaveBeenCalled(); + }); +}); diff --git a/src/components/bookmarks-panel/upload-panel.tsx b/src/components/upload-panel/upload-panel.tsx similarity index 66% rename from src/components/bookmarks-panel/upload-panel.tsx rename to src/components/upload-panel/upload-panel.tsx index 1aec91ed..9d5ac8e1 100644 --- a/src/components/bookmarks-panel/upload-panel.tsx +++ b/src/components/upload-panel/upload-panel.tsx @@ -1,21 +1,14 @@ import styled from "styled-components"; -import JsonSchema, { - Result, - SchemaDocument, - Validator, -} from "@hyperjump/json-schema"; + +import { FileType, FileUploaded } from "../../types"; import { UploadPanelItem } from "./upload-panel-item"; + import UploadIcon from "../../../public/icons/upload.svg"; import { Layout } from "../../utils/enums"; import { useRef, useState } from "react"; -import { - bookmarksSchemaId, - bookmarksSchemaJson, -} from "../../constants/json-schemas/bookmarks"; -import { Bookmark } from "../../types"; import { useAppLayout } from "../../utils/hooks/layout"; -const UPLOAD_INPUT_ID = "upload-bookmarks-input"; +const UPLOAD_INPUT_ID = "upload-file-input"; const FileInteractionContainer = styled.label` box-sizing: border-box; @@ -26,7 +19,7 @@ const FileInteractionContainer = styled.label` width: 100%; height: 178px; background: ${({ theme }) => theme.colors.mainHiglightColor}; - border: 1px dashed ${({ theme }) => theme.colors.bookmarkFileInteracrions}; + border: 1px dashed ${({ theme }) => theme.colors.bookmarkFileInteractions}; border-radius: 4px; cursor: pointer; `; @@ -62,45 +55,48 @@ const UploadInput = styled.input` display: none; `; -type ExistedLayerWarningProps = { +type UploadProps = { + title: string; + dragAndDropText: string; + fileType: FileType; + multipleFiles?: boolean; onCancel: () => void; - onBookmarksUploaded: (bookmarks: Bookmark[]) => void; + onFileUploaded: (fileUploaded: FileUploaded) => void; }; -export const dragAndDropText = "Drag and drop you json file here"; - export const UploadPanel = ({ + title, + dragAndDropText, + fileType, + multipleFiles, onCancel, - onBookmarksUploaded, -}: ExistedLayerWarningProps) => { + onFileUploaded, +}: UploadProps) => { const layout = useAppLayout(); const [dragActive, setDragActive] = useState(false); const inputRef = useRef(null); - const parseFile = async (file) => { - const reader = new FileReader(); - reader.onload = async (event) => { - if (typeof event?.target?.result !== "string") { - return; - } - JsonSchema.add(bookmarksSchemaJson); - const schema: SchemaDocument = await JsonSchema.get(bookmarksSchemaId); - let result; - try { - const validator: Validator = await JsonSchema.validate(schema); - result = JSON.parse(event.target.result); - const validationResult: Result = validator(result); - if (validationResult.valid) { - onBookmarksUploaded(result); + const readFile = async (files: FileList) => { + for (const file of files) { + const reader = new FileReader(); + reader.onload = async (event) => { + if (!event?.target?.result) { + return; } - } catch { - // do nothing + const info: Record = { + url: file.name, + }; + onFileUploaded({ fileContent: event?.target?.result, info: info }); + }; + if (fileType === FileType.binary) { + reader.readAsArrayBuffer(file); + } else if (fileType === FileType.text) { + reader.readAsText(file); } - }; - reader.readAsText(file); + } }; - const onDragHandler = (e) => { + const onDragHandler = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.type === "dragenter" || e.type === "dragover") { @@ -110,31 +106,31 @@ export const UploadPanel = ({ } }; - const onDropHandler = (e) => { + const onDropHandler = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragActive(false); if (e.dataTransfer.files && e.dataTransfer.files[0]) { - parseFile(e.dataTransfer.files[0]); + readFile(e.dataTransfer.files); } }; - const onUploadChangeHandler = function (e) { + const onUploadChangeHandler = function ( + e: React.ChangeEvent + ) { e.preventDefault(); if (e.target.files && e.target.files[0]) { - parseFile(e.target.files[0]); + readFile(e.target.files); } }; return ( - + { + it("Selector should return the initial state", () => { + const store = setupStore(); + const state = store.getState(); + expect(selectIconList("")(state)).toEqual([]); + const list = selectIconList(IconListSetName.uvDebugTexture)(state); + expect(list.length).toEqual(5); + }); + + it("Selectors should return picked items", () => { + const store = setupStore(); + const state = store.getState(); + const id = selectIconItemPickedId(IconListSetName.uvDebugTexture)(state); + expect(id).toEqual("uv1"); + const itemPicked = selectIconItemPicked(IconListSetName.uvDebugTexture)( + state + ); + expect(itemPicked?.id).toEqual("uv1"); + }); + + it("Should add an icon item and set it as a current one", () => { + const store = setupStore(); + const texture: IIconItem = { + id: `myTexture`, + icon: "", + extData: { imageUrl: "path" }, + custom: true, + }; + store.dispatch( + addIconItem({ + iconListSetName: IconListSetName.uvDebugTexture, + iconItem: texture, + setCurrent: true, + }) + ); + const state = store.getState(); + const itemPicked = selectIconItemPicked(IconListSetName.uvDebugTexture)( + state + ); + expect(itemPicked?.id).toEqual("myTexture"); + }); + + it("Should remove an icon item and clear the selection if necessary", () => { + const store = setupStore(); + store.dispatch( + deleteIconItem({ + iconListSetName: IconListSetName.uvDebugTexture, + id: "uv2", + }) + ); + const state = store.getState(); + const itemPicked1 = selectIconItemPicked(IconListSetName.uvDebugTexture)( + state + ); + expect(itemPicked1?.id).toEqual("uv1"); + + // Delete the picked item + store.dispatch( + deleteIconItem({ + iconListSetName: IconListSetName.uvDebugTexture, + id: "uv1", + }) + ); + const newState = store.getState(); + const itemPicked2 = selectIconItemPicked(IconListSetName.uvDebugTexture)( + newState + ); + expect(itemPicked2).toEqual(null); + }); + + it("Should set an icon item as a picked one", () => { + const store = setupStore(); + store.dispatch( + setIconItemPicked({ + iconListSetName: IconListSetName.uvDebugTexture, + id: "uv2", + }) + ); + const state = store.getState(); + const itemPicked1 = selectIconItemPicked(IconListSetName.uvDebugTexture)( + state + ); + expect(itemPicked1?.id).toEqual("uv2"); + }); +}); diff --git a/src/redux/slices/icon-list-slice.ts b/src/redux/slices/icon-list-slice.ts new file mode 100644 index 00000000..696b85e8 --- /dev/null +++ b/src/redux/slices/icon-list-slice.ts @@ -0,0 +1,152 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { RootState } from "../store"; +import { IIconItem, IconListSetName } from "../../types"; + +import uv1 from "../../../public/images/uvTexture1.png"; +import uv1Icon from "../../../public/images/uvTexture1.thumb.png"; +import uv2 from "../../../public/images/uvTexture2.png"; +import uv2Icon from "../../../public/images/uvTexture2.thumb.png"; +import uv3 from "../../../public/images/uvTexture3.png"; +import uv3Icon from "../../../public/images/uvTexture3.thumb.png"; +import uv4 from "../../../public/images/uvTexture4.png"; +import uv4Icon from "../../../public/images/uvTexture4.thumb.png"; +import uv5 from "../../../public/images/uvTexture5.png"; +import uv5Icon from "../../../public/images/uvTexture5.thumb.png"; + +/** + * IconListSet "BaseMap" + * [ + * {group:"ArcGIS", icon:"1", id:"k10", custom:false, name:"A", extData: { mapUrl:"x1" }}, + * {group:"ArcGIS", icon:"2", id:"k11", custom:false, name:"B", extData: { mapUrl:"x1" }}, + * {group:"Maplibre", icon:"3", id:"k20", custom:false, name:"C", extData: { mapUrl:"x1" }}, + * {group:"Maplibre", icon:"4", id:"k21", custom:false, name:"D", extData: { mapUrl:"x1" }}, + * {group:"UserMap", icon:"5", id:"k30", custom:true, name:"C", extData: { mapUrl:"x1" }}, + * {group:"UserMap", icon:"6", id:"k31", custom:true, name:"D", extData: { mapUrl:"x1" }}, + * ] + * pickedId: "k20" + * + * IconListSet "uvDebugTexture" + * [ + * {icon:"6", id:"k60", custom:false, extData: { imageUrl:"x6" }}, + * {icon:"7", id:"k61", custom:true, extData: { imageUrl:"x7" }}, + * {icon:"8", id:"k70", custom:false, extData: { imageUrl:"x8" }}, + * {icon:"9", id:"k71", custom:true, extData: { imageUrl:"x9" }}, + * ] + * pickedId: "k70" + */ +interface IIconListSet { + iconList: IIconItem[]; + iconItemIdPicked: string; +} + +/** + * @example "BaseMap", "uvDebugTexture", "desktopBackground" + */ +interface IIconListState { + iconListSets: Record; +} +const initialState: IIconListState = { + iconListSets: { + [IconListSetName.uvDebugTexture]: { + iconList: [ + { id: "uv1", icon: uv1Icon, extData: { imageUrl: uv1 } }, + { id: "uv2", icon: uv2Icon, extData: { imageUrl: uv2 } }, + { id: "uv3", icon: uv3Icon, extData: { imageUrl: uv3 } }, + { id: "uv4", icon: uv4Icon, extData: { imageUrl: uv4 } }, + { id: "uv5", icon: uv5Icon, extData: { imageUrl: uv5 } }, + ], + iconItemIdPicked: "uv1", + }, + }, +}; + +const iconListSlice = createSlice({ + name: "iconList", + initialState, + reducers: { + addIconItem: ( + state: IIconListState, + action: PayloadAction<{ + iconListSetName: string; + iconItem: IIconItem; + setCurrent: boolean; + }> + ) => { + const arg = action.payload; + if (!state.iconListSets[arg.iconListSetName]) { + state.iconListSets[arg.iconListSetName] = { + iconList: [], + iconItemIdPicked: "", + }; + } + const element = + arg.iconItem.id && + state.iconListSets[arg.iconListSetName].iconList.find( + (item) => item.id === arg.iconItem.id + ); + if (!element) { + state.iconListSets[arg.iconListSetName].iconList.push(arg.iconItem); + } + if (arg.setCurrent) { + state.iconListSets[arg.iconListSetName].iconItemIdPicked = + arg.iconItem.id; + } + }, + setIconItemPicked: ( + state: IIconListState, + action: PayloadAction<{ + iconListSetName: string; + id: string; + }> + ) => { + const arg = action.payload; + state.iconListSets[arg.iconListSetName].iconItemIdPicked = arg.id; + }, + deleteIconItem: ( + state: IIconListState, + action: PayloadAction<{ + iconListSetName: string; + id: string; + }> + ) => { + const arg = action.payload; + const sets = state.iconListSets[arg.iconListSetName]; + const element = sets?.iconList.find((item) => item.id === arg.id); + if (element) { + sets.iconList = sets.iconList.filter((item) => item.id !== arg.id); + if (sets.iconItemIdPicked === arg.id) { + sets.iconItemIdPicked = ""; + } + } + }, + }, +}); + +export const selectIconList = + (iconListSetName: string, group?: string) => + (state: RootState): IIconItem[] => { + const panes = state.iconList.iconListSets[iconListSetName]?.iconList || []; + return group ? panes.filter((item) => item.group === group) : panes; + }; + +export const selectIconItemPicked = + (iconListSetName: string) => + (state: RootState): IIconItem | null => { + const iconListSet = state.iconList.iconListSets[iconListSetName]; + const texture = iconListSet?.iconList.find( + (item) => item.id === iconListSet.iconItemIdPicked + ); + return texture || null; + }; + +export const selectIconItemPickedId = + (iconListSetName: string) => + (state: RootState): string => { + const iconListSet = state.iconList.iconListSets[iconListSetName]; + return iconListSet?.iconItemIdPicked || ""; + }; + +export const { addIconItem, deleteIconItem, setIconItemPicked } = + iconListSlice.actions; + +export default iconListSlice.reducer; diff --git a/src/redux/slices/uv-debug-texture-slice.spec.ts b/src/redux/slices/uv-debug-texture-slice.spec.ts index c9684700..83e69fe8 100644 --- a/src/redux/slices/uv-debug-texture-slice.spec.ts +++ b/src/redux/slices/uv-debug-texture-slice.spec.ts @@ -18,21 +18,21 @@ describe("slice: uv-debug-texture", () => { it("Selector should return the initial state", () => { const store = setupStore(); const state = store.getState(); - expect(selectUVDebugTexture(state)).toEqual(null); + expect(selectUVDebugTexture("")(state)).toEqual(null); }); it("fetchUVDebugTexture should call loading mocked texture and put it into the slice state", async () => { const store = setupStore(); const state = store.getState(); - expect(selectUVDebugTexture(state)).toEqual(null); + const imageUrl = "https://localhost:3000/images/uvTexture1.png"; + expect(selectUVDebugTexture(imageUrl)(state)).toEqual(null); - await store.dispatch(fetchUVDebugTexture()); - expect(load).toHaveBeenCalledWith( - "https://raw.githubusercontent.com/visgl/deck.gl-data/master/images/uv-debug-texture.jpg", - ImageLoader - ); + await store.dispatch(fetchUVDebugTexture(imageUrl)); + + expect(load).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledWith(imageUrl, ImageLoader); const newState = store.getState(); - expect(selectUVDebugTexture(newState)).toEqual(imageStubObject); + expect(selectUVDebugTexture(imageUrl)(newState)).toEqual(imageStubObject); }); }); diff --git a/src/redux/slices/uv-debug-texture-slice.ts b/src/redux/slices/uv-debug-texture-slice.ts index fbce55f5..d5092cbb 100644 --- a/src/redux/slices/uv-debug-texture-slice.ts +++ b/src/redux/slices/uv-debug-texture-slice.ts @@ -3,19 +3,19 @@ import { ImageLoader } from "@loaders.gl/images"; import { load } from "@loaders.gl/core"; import { RootState } from "../store"; +type Texture = { + imageUrl: string; + image: ImageBitmap | null; +}; // Define a type for the slice state interface uvDebugTextureState { - /** Image Bitmap for the debug texture */ - value: ImageBitmap | null; + textureArray: Texture[]; } const initialState: uvDebugTextureState = { - value: null, + textureArray: [], }; -const UV_DEBUG_TEXTURE_URL = - "https://raw.githubusercontent.com/visgl/deck.gl-data/master/images/uv-debug-texture.jpg"; - const uvDebugTextureSlice = createSlice({ name: "uvDebugTexture", initialState, @@ -25,27 +25,40 @@ const uvDebugTextureSlice = createSlice({ fetchUVDebugTexture.fulfilled, ( state: uvDebugTextureState, - action: PayloadAction + action: PayloadAction<{ + imageUrl: string; + image: ImageBitmap; + } | null> ) => { - return action.payload; + if (action.payload) { + state.textureArray.push(action.payload); + } } ); }, }); -export const fetchUVDebugTexture = createAsyncThunk( - "fetchUVDebugTexture", - async () => { - const image = (await load( - UV_DEBUG_TEXTURE_URL, - ImageLoader - )) as ImageBitmap; +export const fetchUVDebugTexture = createAsyncThunk< + { imageUrl: string; image: ImageBitmap } | null, + string +>("fetchUVDebugTexture", async (imageUrl, { getState }) => { + const state = (getState() as RootState).uvDebugTexture; + const el = state.textureArray.find((item) => item.imageUrl === imageUrl); - return { value: image }; + if (!el || !el.image) { + const image = (await load(imageUrl, ImageLoader)) as ImageBitmap; + return { imageUrl: imageUrl, image: image }; } -); + return null; +}); -export const selectUVDebugTexture = (state: RootState): ImageBitmap | null => - state.uvDebugTexture.value; +export const selectUVDebugTexture = + (imageUrl: string) => + (state: RootState): ImageBitmap | null => { + const el = state.uvDebugTexture.textureArray.find( + (item) => item.imageUrl === imageUrl + ); + return el?.image || null; + }; export default uvDebugTextureSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index f9b753a9..ea35e75a 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -6,6 +6,7 @@ import { import flattenedSublayersSliceReducer from "./slices/flattened-sublayers-slice"; import dragModeSliceReducer from "./slices/drag-mode-slice"; import uvDebugTextureSliceReducer from "./slices/uv-debug-texture-slice"; +import iconListSliceReducer from "./slices/icon-list-slice"; import debugOptionsSliceReducer from "./slices/debug-options-slice"; import i3sStatsSliceReducer from "./slices/i3s-stats-slice"; import baseMapsSliceReducer from "./slices/base-maps-slice"; @@ -19,6 +20,7 @@ const rootReducer = combineReducers({ flattenedSublayers: flattenedSublayersSliceReducer, dragMode: dragModeSliceReducer, uvDebugTexture: uvDebugTextureSliceReducer, + iconList: iconListSliceReducer, debugOptions: debugOptionsSliceReducer, baseMaps: baseMapsSliceReducer, symbolization: symbolizationSliceReducer, @@ -38,7 +40,7 @@ export const setupStore = (preloadedState?: PreloadedState) => { // Ignore these action types ignoredActions: ["fetchUVDebugTexture/fulfilled"], // Ignore these paths in the state - ignoredPaths: ["uvDebugTexture.value"], + ignoredPaths: ["uvDebugTexture.iconListSets"], }, }), }); diff --git a/src/types.ts b/src/types.ts index e5fa4cca..66253949 100644 --- a/src/types.ts +++ b/src/types.ts @@ -384,3 +384,35 @@ export enum FetchingStatus { pending = "pending", ready = "ready", } + +export enum IconListSetName { + uvDebugTexture = "uvDebugTexture", + baseMap = "baseMap", +} + +export interface IIconItem { + /** Unique id of the item */ + id: string; + /** Icon image that can be a URL or a blob converted to a object URL*/ + icon: string; + /** Name of a group like "Maplibre", "ArcGIS". + * The icon-list-panel can use it to group icon items into separate panels + */ + group?: string; + /** Name (title) of the icon */ + name?: string; + /** Predefined or custom (loaded by a user) icon. */ + custom?: boolean; + /** Additional data linked with the icon like a texture, basemap */ + extData?: Record; +} + +export enum FileType { + binary = "binary", + text = "text", +} + +export type FileUploaded = { + fileContent: string | ArrayBuffer; + info: Record; +}; diff --git a/src/utils/bookmarks-utils.spec.ts b/src/utils/bookmarks-utils.spec.ts index 0ca0fb3e..776eda25 100644 --- a/src/utils/bookmarks-utils.spec.ts +++ b/src/utils/bookmarks-utils.spec.ts @@ -2,6 +2,7 @@ import { PageId } from "../types"; import { convertArcGisSlidesToBookmars, checkBookmarksByPageId, + parseBookmarks, } from "./bookmarks-utils"; jest.mock("@math.gl/proj4", () => ({ @@ -304,4 +305,23 @@ describe("Bookmarks utils", () => { expect(bookmarksPageId).toEqual(PageId.debug); }); + + test("Should return bookmarks parsed", async () => { + const bookmarksExpected = [ + { id: "1", pageId: PageId.debug, imageUrl: "data" }, + { id: "2", pageId: PageId.debug, imageUrl: "data" }, + ]; + + const bookmarksString = + '[{"id":"1", "pageId":"Debug", "imageUrl":"data"},{"id":"2", "pageId":"Debug", "imageUrl":"data"}]'; + const bookmarks = await parseBookmarks(bookmarksString); + expect(bookmarks).toEqual(bookmarksExpected); + }); + + test("Should return null if bookmarks json is invalid", async () => { + const bookmarksString = + '[{"id":"1", "pageId":"Debug"},{"id":"2", "pageId":"Debug"]'; + const bookmarks = await parseBookmarks(bookmarksString); + expect(bookmarks).toEqual(null); + }); }); diff --git a/src/utils/bookmarks-utils.ts b/src/utils/bookmarks-utils.ts index 8f3cfc1d..94458bad 100644 --- a/src/utils/bookmarks-utils.ts +++ b/src/utils/bookmarks-utils.ts @@ -6,6 +6,17 @@ import { Bookmark, PageId, LayerExample, LayerViewState } from "../types"; import { getLonLatWithElevationOffset } from "./elevation-utils"; import { flattenLayerIds } from "./layer-utils"; +import { + bookmarksSchemaId, + bookmarksSchemaJson, +} from "../constants/json-schemas/bookmarks"; + +import JsonSchema, { + Result, + SchemaDocument, + Validator, +} from "@hyperjump/json-schema"; + const PSEUDO_MERCATOR_CRS_WKIDS = [102100, 3857]; /** @@ -145,7 +156,7 @@ const convertArcGisCameraPositionToBookmarkViewState = ( }; /** - * Try to find bookmars in boomarks list which is not aplicable to current page + * Try to find bookmars in bookmarks list which is not aplicable to current page * If page ids of all bookmarks are the same as current page id return current pageId if not return pageId of bookmark. * @param bookmarks * @param pageId @@ -162,3 +173,26 @@ export const checkBookmarksByPageId = ( return pageId; }; + +/** + * Parses json string containing bookmarks according to the schema. + * @param bookmarks + * @returns Array of bookmarks or null if the json string is invalid. + */ +export const parseBookmarks = async ( + bookmarks: string +): Promise => { + JsonSchema.add(bookmarksSchemaJson); + const schema: SchemaDocument = await JsonSchema.get(bookmarksSchemaId); + try { + const validator: Validator = await JsonSchema.validate(schema); + const bookmarksParsed: Bookmark[] = JSON.parse(bookmarks); + const validationResult: Result = validator(bookmarksParsed); + if (validationResult.valid) { + return bookmarksParsed; + } + } catch (error) { + console.log("Invalid bookmark json"); + } + return null; +}; diff --git a/src/utils/debug/texture-render-utils.spec.ts b/src/utils/debug/texture-render-utils.spec.ts index 26da9796..421268f8 100644 --- a/src/utils/debug/texture-render-utils.spec.ts +++ b/src/utils/debug/texture-render-utils.spec.ts @@ -4,7 +4,7 @@ const mockDrawImage = jest.fn(); Object.defineProperty(window, "createImageBitmap", { writable: true, - value: jest.fn().mockImplementation(() => ({})), + value: jest.fn().mockImplementation((image) => image), }); Object.defineProperty(window, "ImageBitmap", { diff --git a/src/utils/debug/texture-render-utils.ts b/src/utils/debug/texture-render-utils.ts index c2ecd86b..8e059bd0 100644 --- a/src/utils/debug/texture-render-utils.ts +++ b/src/utils/debug/texture-render-utils.ts @@ -246,25 +246,17 @@ void main() { } `; -/** - * Draws and rescales the image provided - * @param image - image to draw - * @param size - size of the image drawn - * @returns texture drawn and its size - */ -export const drawBitmapTexture = async ( - image: ImageData | HTMLCanvasElement, +export const drawBitmap = ( + bitmap: ImageBitmap, size: number -): Promise<{ url: string; width: number; height: number }> => { - const bitmap = await createImageBitmap(image); - +): { url: string; width: number; height: number } => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) { throw new Error("No 2d context"); } - const imageWidth = image.width; - const imageHeight = image.height; + const imageWidth = bitmap.width; + const imageHeight = bitmap.height; canvas.width = imageWidth; canvas.height = imageHeight; @@ -282,6 +274,21 @@ export const drawBitmapTexture = async ( width: areaWidth, height: areaHeight, }; +} + + +/** + * Draws and rescales the image provided + * @param image - image to draw + * @param size - size of the image drawn + * @returns texture drawn and its size + */ +export const drawBitmapTexture = async ( + image: ImageData | HTMLCanvasElement, + size: number +): Promise<{ url: string; width: number; height: number }> => { + const bitmap = await createImageBitmap(image); + return drawBitmap(bitmap, size); }; /**