diff --git a/src/components/main-space/new-icicle/icicles-container.tsx b/src/components/main-space/new-icicle/icicles-container.tsx index 845705812..b8055bdc5 100644 --- a/src/components/main-space/new-icicle/icicles-container.tsx +++ b/src/components/main-space/new-icicle/icicles-container.tsx @@ -1,8 +1,13 @@ -import React, { FC } from "react"; -import { useSelector } from "react-redux"; +import React, { FC, useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; import { getFilesAndFoldersFromStore } from "reducers/files-and-folders/files-and-folders-selectors"; import Icicles from "./icicles"; import { getFilesAndFoldersMetadataFromStore } from "reducers/files-and-folders-metadata/files-and-folders-metadata-selectors"; +import { + setHoveredElementId, + setLockedElementId, +} from "reducers/workspace-metadata/workspace-metadata-actions"; +import { getWorkspaceMetadataFromStore } from "reducers/workspace-metadata/workspace-metadata-selectors"; const IciclesContainer: FC = () => { const filesAndFoldersMap = useSelector(getFilesAndFoldersFromStore); @@ -10,10 +15,40 @@ const IciclesContainer: FC = () => { getFilesAndFoldersMetadataFromStore ); + const { lockedElementId } = useSelector(getWorkspaceMetadataFromStore); + + const dispatch = useDispatch(); + + const setHoveredElement = useCallback( + (id) => dispatch(setHoveredElementId(id)), + [dispatch] + ); + + const resetHoveredElement = useCallback( + () => dispatch(setHoveredElementId("")), + [dispatch] + ); + + const setLockedElement = useCallback( + (id) => { + dispatch(setLockedElementId(id)); + }, + [dispatch] + ); + const resetLockedElement = useCallback( + () => dispatch(setLockedElementId("")), + [dispatch] + ); + return ( ); }; diff --git a/src/components/main-space/new-icicle/icicles-elements.ts b/src/components/main-space/new-icicle/icicles-elements.ts new file mode 100644 index 000000000..a329a3d05 --- /dev/null +++ b/src/components/main-space/new-icicle/icicles-elements.ts @@ -0,0 +1,89 @@ +import { ROOT_FF_ID } from "reducers/files-and-folders/files-and-folders-selectors"; +import { FilesAndFolders } from "reducers/files-and-folders/files-and-folders-types"; +import { fromFileName } from "./../../../util/color/color-util"; +import { + getRectangleHeight, + isLabelVisible, + format, + VIEWBOX_WIDTH, + VIEWBOX_HEIGHT, + handleZoom, +} from "./icicles-utils"; +import { FOLDER_COLOR } from "util/color/color-util"; +import * as d3 from "d3"; + +export const createPartition = (filesAndFolders) => { + const root = d3 + .hierarchy( + filesAndFolders[ROOT_FF_ID], + (element) => + element?.children.map((childId) => filesAndFolders[childId]) ?? [] + ) + .sum((d) => d?.file_size ?? 0); + return d3 + .partition() + .size([VIEWBOX_HEIGHT, ((root.height + 1) * VIEWBOX_WIDTH) / 10])(root); +}; + +export const createSvg = (ref) => + d3 + .select(ref.current) + .append("svg") + .attr("viewBox", [0, 0, VIEWBOX_WIDTH, VIEWBOX_HEIGHT].join(" ")) + .style("font", "10px Quicksand"); + +export const createCell = (svg, root) => { + return svg + .selectAll("g") + .data(root.descendants()) + .join("g") + .attr("transform", ({ x0, y0 }) => `translate(${y0},${x0})`); +}; + +export const createRect = (cell, elements) => { + return cell + .append("rect") + .attr("width", ({ y0, y1 }) => y1 - y0 - 1) + .attr("height", (d) => getRectangleHeight(d)) + .attr("fill-opacity", 0.5) + .attr("fill", (d: any) => { + if (!d.depth) { + return "#ccc"; + } + if (d.data.children.length > 0) { + return FOLDER_COLOR; + } + return fromFileName(d.data.name); + }) + .style("cursor", "pointer") + .on("dblclick", (_, currentRect) => handleZoom(_, currentRect, elements)); +}; + +export const createTitle = (cellElement) => { + const cell = cellElement + .append("text") + .style("user-select", "none") + .attr("pointer-events", "none") + .attr("x", 4) + .attr("y", 13) + .attr("fill-opacity", (d) => +isLabelVisible(d)) + .append("tspan") + .text((d: any) => d.data.name); + + cell.append("title").text( + (d: any) => + `${d + .ancestors() + .map((d: any) => d.data.name) + .reverse() + .join("/")}\n${format(d.value)}` + ); + return cell; +}; + +export const createSubtitle = (title) => { + return title + .append("tspan") + .attr("fill-opacity", (d: any) => (isLabelVisible(d) ? 0.7 : 0)) + .text((d: any) => ` ${format(d.value)}`); +}; diff --git a/src/components/main-space/new-icicle/icicles-utils.ts b/src/components/main-space/new-icicle/icicles-utils.ts new file mode 100644 index 000000000..1fe3f4912 --- /dev/null +++ b/src/components/main-space/new-icicle/icicles-utils.ts @@ -0,0 +1,79 @@ +import * as d3 from "d3"; +export const VIEWBOX_WIDTH = 1000; +export const VIEWBOX_HEIGHT = 300; +export const TRANSITION_DURATION = 750; + +type Dimensions = { + x0: number; + y0: number; + x1: number; + y1: number; +}; + +export const getRectangleHeight = ({ x0, x1 }): number => { + return x1 - x0 - Math.min(1, (x1 - x0) / 2); +}; + +export const getCurrentRect = (icicles, currentElementId) => { + const rects = getAllRects(icicles); + return rects.filter(({ data: { id } }) => id === currentElementId.data.id); +}; + +export const getAllRects = (icicles) => + d3.select(icicles.current).selectAll("rect"); + +export const getRectById = (icicles, rectId) => { + const rects = getAllRects(icicles); + return rects.filter(({ data: { id } }) => id === rectId); +}; + +export const format = d3.format(",d"); + +export const isLabelVisible = ({ x0, x1, y0, y1 }): boolean => { + return y1 <= VIEWBOX_WIDTH && y0 >= 0 && x1 - x0 > 16; +}; + +export const dimensionsTarget: Record = {}; + +export const getDimensions = (dimensions) => + dimensionsTarget[dimensions.data.id]; + +export const handleZoom = (_, currentRect, elements) => { + let { root, cell, rect, title, subtitle } = elements; + + elements.focus = + elements.focus === currentRect + ? (currentRect = currentRect.parent) + : currentRect; + + root.each((dimensions) => { + dimensionsTarget[dimensions.data.id] = { + x0: + ((dimensions.x0 - currentRect.x0) / (currentRect.x1 - currentRect.x0)) * + VIEWBOX_HEIGHT, + x1: + ((dimensions.x1 - currentRect.x0) / (currentRect.x1 - currentRect.x0)) * + VIEWBOX_HEIGHT, + y0: dimensions.y0 - currentRect.y0, + y1: dimensions.y1 - currentRect.y0, + }; + }); + + const transition = cell + .transition() + .duration(TRANSITION_DURATION) + .attr( + "transform", + (d: any) => `translate(${getDimensions(d).y0},${getDimensions(d).x0})` + ); + + rect + .transition(transition) + .attr("height", (d: any) => getRectangleHeight(getDimensions(d))); + title + .transition(transition) + .attr("fill-opacity", (d: any) => +isLabelVisible(getDimensions(d))); + subtitle + .transition(transition) + .attr("fill-opacity", (d: any) => +isLabelVisible(getDimensions(d)) * 0.7); +}; diff --git a/src/components/main-space/new-icicle/icicles.tsx b/src/components/main-space/new-icicle/icicles.tsx index 5cc3039da..171683f1b 100644 --- a/src/components/main-space/new-icicle/icicles.tsx +++ b/src/components/main-space/new-icicle/icicles.tsx @@ -1,158 +1,112 @@ -import React, { FC, useEffect, useRef } from "react"; -import * as d3 from "d3"; -import { - FilesAndFolders, - FilesAndFoldersMap, -} from "reducers/files-and-folders/files-and-folders-types"; -import { ROOT_FF_ID } from "reducers/files-and-folders/files-and-folders-selectors"; +import React, { FC, useEffect, useRef, useState } from "react"; +import { FilesAndFoldersMap } from "reducers/files-and-folders/files-and-folders-types"; import { FilesAndFoldersMetadataMap } from "reducers/files-and-folders-metadata/files-and-folders-metadata-types"; -import { FOLDER_COLOR, fromFileName } from "util/color/color-util"; - -type Dimensions = { - x0: number; - y0: number; - x1: number; - y1: number; -}; +import { getAllRects, getCurrentRect, getRectById } from "./icicles-utils"; +import { + createCell, + createRect, + createSvg, + createTitle, + createSubtitle, + createPartition, +} from "./icicles-elements"; type IciclesProps = { filesAndFolders: FilesAndFoldersMap; filesAndFoldersMetadata: FilesAndFoldersMetadataMap; + setHoveredElement: (id: string) => void; + resetHoveredElement: () => void; + setLockedElement: (id: string) => void; + resetLockedElement: () => void; + lockedElementId: string; }; -const viewboxWidth = 1000; -const viewboxHeight = 300; -const TRANSITION_DURATION = 750; - const Icicles: FC = ({ filesAndFolders, - filesAndFoldersMetadata, + setHoveredElement, + setLockedElement, + resetLockedElement, + lockedElementId, }) => { + const [currentHoveredElementId, setCurrentHoveredElementId] = useState(""); + const [currentLockedElementId, setCurrentLockedElementId] = useState(""); const iciclesRef = useRef(null); - const format = d3.format(",d"); + const root = createPartition(filesAndFolders); + let focus = root; - const getRectangleHeight = ({ x0, x1 }): number => { - return x1 - x0 - Math.min(1, (x1 - x0) / 2); + const handleResetLockedElement = ({ target, currentTarget }) => { + if (target === currentTarget && currentLockedElementId.length) { + resetLockedElement(); + setCurrentLockedElementId(""); + getAllRects(iciclesRef).style("fill-opacity", 0.5); + } }; - const isLabelVisible = ({ x0, x1, y0, y1 }): boolean => { - return y1 <= viewboxWidth && y0 >= 0 && x1 - x0 > 16; + const handleLockedElement = (_, lockedElement) => { + if (lockedElement.data.id === currentLockedElementId) return; + + setLockedElement(lockedElement.data.id); + setCurrentLockedElementId(lockedElement.data.id); + getCurrentRect(iciclesRef, lockedElement).style("fill-opacity", 1); + + currentLockedElementId.length + ? getRectById(iciclesRef, currentLockedElementId).style( + "fill-opacity", + 0.5 + ) + : null; }; - const partition = () => { - const root = d3 - .hierarchy( - filesAndFolders[ROOT_FF_ID], - (element) => - element?.children.map((childId) => filesAndFolders[childId]) ?? [] - ) - .sum((d) => d?.file_size ?? 0); - - return d3 - .partition() - .size([viewboxHeight, ((root.height + 1) * viewboxWidth) / 10])(root); + const handleSetCurrentHoveredElementId = (_, hoveredElement) => { + if (hoveredElement.data.id === currentLockedElementId) return; + + setCurrentHoveredElementId(hoveredElement.data.id); + getCurrentRect(iciclesRef, hoveredElement).style("fill-opacity", 0.75); }; - const root = partition(); + const handleResetCurrentHoveredElementId = (_, hoveredElement) => { + if (hoveredElement.data.id === currentLockedElementId) return; - let focus = root; + setCurrentHoveredElementId(""); + getCurrentRect(iciclesRef, hoveredElement).style("fill-opacity", 0.5); + }; useEffect(() => { - const onClicked = (event, p) => { - focus = focus === p ? (p = p.parent) : p; - - const dimensionsTarget: Record = {}; - - const getDimensions = (dimensions) => - dimensionsTarget[dimensions.data.id]; - - root.each((dimensions) => { - dimensionsTarget[dimensions.data.id] = { - x0: ((dimensions.x0 - p.x0) / (p.x1 - p.x0)) * viewboxHeight, - x1: ((dimensions.x1 - p.x0) / (p.x1 - p.x0)) * viewboxHeight, - y0: dimensions.y0 - p.y0, - y1: dimensions.y1 - p.y0, - }; - }); - - const transition = cell - .transition() - .duration(TRANSITION_DURATION) - .attr( - "transform", - (d: any) => `translate(${getDimensions(d).y0},${getDimensions(d).x0})` - ); - - rect - .transition(transition) - .attr("height", (d: any) => getRectangleHeight(getDimensions(d))); - text - .transition(transition) - .attr("fill-opacity", (d: any) => +isLabelVisible(getDimensions(d))); - tspan - .transition(transition) - .attr( - "fill-opacity", - (d: any) => +isLabelVisible(getDimensions(d)) * 0.7 - ); - }; - - const svg = d3 - .select(iciclesRef.current) - .append("svg") - .attr("viewBox", [0, 0, viewboxWidth, viewboxHeight].join(" ")) - .style("font", "10px Quicksand"); - - const cell = svg - .selectAll("g") - .data(root.descendants()) - .join("g") - .attr("transform", ({ x0, y0 }) => `translate(${y0},${x0})`); - - const rect = cell - .append("rect") - .attr("width", ({ y0, y1 }) => y1 - y0 - 1) - .attr("height", (d) => getRectangleHeight(d)) - .attr("fill-opacity", 0.6) - .attr("fill", (d: any) => { - if (!d.depth) { - return "#ccc"; - } - if (d.data.children.length > 0) { - return FOLDER_COLOR; - } - return fromFileName(d.data.name); - }) - .style("cursor", "pointer") - .on("click", onClicked); - - const text = cell - .append("text") - .style("user-select", "none") - .attr("pointer-events", "none") - .attr("x", 4) - .attr("y", 13) - .attr("fill-opacity", (d) => +isLabelVisible(d)); - - text.append("tspan").text((d: any) => d.data.name); - - const tspan = text - .append("tspan") - .attr("fill-opacity", (d: any) => (isLabelVisible(d) ? 0.7 : 0)) - .text((d: any) => ` ${format(d.value)}`); - - cell.append("title").text( - (d: any) => - `${d - .ancestors() - .map((d: any) => d.data.name) - .reverse() - .join("/")}\n${format(d.value)}` - ); + if (!lockedElementId) { + setHoveredElement(currentHoveredElementId); + } + }, [lockedElementId, setHoveredElement, currentHoveredElementId]); + + useEffect(() => { + const iciclesElements = {}; + + const svg = createSvg(iciclesRef); + const cell = createCell(svg, root); + const rect = createRect(cell, iciclesElements); + const title = createTitle(cell); + const subtitle = createSubtitle(title); + + Object.assign(iciclesElements, { + root, + focus, + cell, + rect, + title, + subtitle, + }); }, []); - return ; + useEffect(() => { + getAllRects(iciclesRef) + .on("click", handleLockedElement) + .on("mouseover", handleSetCurrentHoveredElementId) + .on("mouseout", handleResetCurrentHoveredElementId); + }, [currentLockedElementId, iciclesRef]); + + return ( + + ); }; export default Icicles; diff --git a/src/css/index.scss b/src/css/index.scss index 850acc5e0..3afc697e0 100644 --- a/src/css/index.scss +++ b/src/css/index.scss @@ -14,3 +14,8 @@ body { h4 { font-family: "Quicksand", sans-serif; } + +#icicles { + width: 100%; + height: 80%; +}