Synchronize map and table

dev
Platon Yasev 3 years ago
parent cbe5aebb41
commit 0e166a490d

@ -27,6 +27,7 @@
"react-icons": "^4.6.0", "react-icons": "^4.6.0",
"react-map-gl": "^7.0.19", "react-map-gl": "^7.0.19",
"react-router-dom": "^6.8.1", "react-router-dom": "^6.8.1",
"scroll-into-view-if-needed": "^3.0.6",
"tailwind-merge": "^1.7.0", "tailwind-merge": "^1.7.0",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"vite-plugin-svgr": "^2.4.0", "vite-plugin-svgr": "^2.4.0",

@ -9,23 +9,26 @@ import { SignOut } from "../SignOut";
import debounce from "lodash.debounce"; import debounce from "lodash.debounce";
import { Table } from "../modules/Table/Table"; import { Table } from "../modules/Table/Table";
import { usePopup } from "../stores/usePopup"; import { usePopup } from "../stores/usePopup";
import { useClickedPoint } from "../stores/useClickedPoint"; import { useClickedPointLocationId } from "../stores/useClickedPointLocationId";
import { Legend } from "../modules/Sidebar/Legend";
export const MapComponent = () => { export const MapComponent = () => {
const mapRef = useRef(null); const mapRef = useRef(null);
const mapContainerRef = useRef(null); const mapContainerRef = useRef(null);
const { popup, setPopup } = usePopup(); const { popup, setPopup } = usePopup();
const { setClickedPoint } = useClickedPoint(); const { setClickedPointConfig } = useClickedPointLocationId();
const handleClick = (event) => { const handleClick = (event) => {
if (!event.features) { if (!event.features) {
setPopup(null); setPopup(null);
setClickedPointConfig(null);
return; return;
} }
const feature = event.features[0]; const feature = event.features[0];
if (!feature) { if (!feature) {
setPopup(null); setPopup(null);
setClickedPointConfig(null);
return; return;
} }
@ -98,7 +101,7 @@ export const MapComponent = () => {
features={popup.features} features={popup.features}
onClose={() => { onClose={() => {
setPopup(null); setPopup(null);
setClickedPoint(null); setClickedPointConfig(null);
}} }}
/> />
)} )}
@ -108,7 +111,7 @@ export const MapComponent = () => {
<Sidebar /> <Sidebar />
{/*<Legend />*/} <Legend />
<SignOut /> <SignOut />
</Map> </Map>
</div> </div>

@ -4,9 +4,7 @@ import { twMerge } from "tailwind-merge";
import { usePointSelection } from "../stores/usePointSelection"; import { usePointSelection } from "../stores/usePointSelection";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { CATEGORIES } from "../config"; import { CATEGORIES } from "../config";
import { useQuery } from "@tanstack/react-query"; import { useClickedPointLocationId } from "../stores/useClickedPointLocationId";
import { api } from "../api";
import { useClickedPoint } from "../stores/useClickedPoint";
const popupConfig = [ const popupConfig = [
{ {
@ -108,32 +106,17 @@ const PopupWrapper = ({ lat, lng, onClose, children }) => {
); );
}; };
const SingleFeaturePopup = ({ feature, onSelect }) => { const SingleFeaturePopup = ({ feature }) => {
const { include, selection, exclude } = usePointSelection(); const { include, selection, exclude } = usePointSelection();
const { setClickedPoint } = useClickedPoint(); const { setClickedPointConfig } = useClickedPointLocationId();
const doesMatchFilter = feature.layer.id === "match-points"; const doesMatchFilter = feature.layer.id === "match-points";
const featureId = feature.properties.id; const featureId = feature.properties.id;
const locationId = feature.properties.location_id;
const { data } = useQuery(["clicked-point", locationId], async () => { useEffect(
const params = new URLSearchParams({ () =>
"location_ids[]": [locationId], setClickedPointConfig(feature.properties.location_id, doesMatchFilter),
}); [feature]
);
const { data } = await api.get(
`/api/placement_points?${params.toString()}`
);
return data;
});
useEffect(() => {
if (!data) {
return;
}
setClickedPoint(data.results[0]);
}, [data]);
const isResidential = feature.properties.category === CATEGORIES.residential; const isResidential = feature.properties.category === CATEGORIES.residential;
@ -149,7 +132,7 @@ const SingleFeaturePopup = ({ feature, onSelect }) => {
} else { } else {
include(featureId); include(featureId);
} }
onSelect(); // onSelect();
}; };
return ( return (
@ -199,13 +182,11 @@ export const MapPopup = ({ features, lat, lng, onClose }) => {
const getContent = () => { const getContent = () => {
if (features.length === 1) { if (features.length === 1) {
return <SingleFeaturePopup feature={features[0]} onSelect={onClose} />; return <SingleFeaturePopup feature={features[0]} />;
} }
if (selectedFeature) { if (selectedFeature) {
return ( return <SingleFeaturePopup feature={selectedFeature} />;
<SingleFeaturePopup feature={selectedFeature} onSelect={onClose} />
);
} }
return ( return (

@ -4,35 +4,53 @@
// dk - дома культуры и клубы // dk - дома культуры и клубы
// sport - спортивные объекты // sport - спортивные объекты
import { CATEGORIES } from "../config";
export const pointColors = { export const pointColors = {
kiosk: "#4561ff", [CATEGORIES.kiosk]: "#4561ff",
mfc: "#932301", [CATEGORIES.mfc]: "#932301",
library: "#a51eda", [CATEGORIES.library]: "#a51eda",
dk: "#ff5204", [CATEGORIES.dk]: "#ff5204",
sport: "#138c44", [CATEGORIES.sport]: "#138c44",
[CATEGORIES.residential]: "#f8e502",
[CATEGORIES.retail]: "#002398",
}; };
export const pointLayer = { export const pointLayer = {
type: "circle", type: "circle",
layout: {}, layout: {},
paint: { paint: {
// "circle-color": [ "circle-color": [
// "match", "match",
// ["get", "category"], ["get", "category"],
// "kiosk", CATEGORIES.kiosk,
// pointColors.kiosk, pointColors[CATEGORIES.kiosk],
// "mfc", CATEGORIES.mfc,
// pointColors.mfc, pointColors[CATEGORIES.mfc],
// "library", CATEGORIES.library,
// pointColors.library, pointColors[CATEGORIES.library],
// "dk", CATEGORIES.dk,
// pointColors.dk, pointColors[CATEGORIES.dk],
// "sport", CATEGORIES.sport,
// pointColors.sport, pointColors[CATEGORIES.sport],
// "black", CATEGORIES.residential,
// ], pointColors[CATEGORIES.residential],
"circle-color": "#a51eda", CATEGORIES.retail,
"circle-radius": 4, pointColors[CATEGORIES.retail],
"black",
],
// "circle-color": "#a51eda",
"circle-radius": [
"interpolate",
["linear"],
["get", "prediction_current"],
0,
0,
200,
2,
300,
10,
],
"circle-stroke-width": 0.4, "circle-stroke-width": 0.4,
"circle-stroke-color": "#fff", "circle-stroke-color": "#fff",
"circle-opacity": 0.8, "circle-opacity": 0.8,

@ -11,5 +11,5 @@ export const CATEGORIES = {
sport: "Спортивный объект", sport: "Спортивный объект",
retail: "Ритейл", retail: "Ритейл",
residential: "Подъезд жилого дома", residential: "Подъезд жилого дома",
culture: "Дом культуры/Клуб", dk: "Дом культуры/Клуб",
}; };

@ -1,31 +1,26 @@
import { Title } from "../../components/Title"; import { Title } from "../../components/Title";
import { pointColors } from "../../Map/layers-config"; import { pointColors } from "../../Map/layers-config";
const types = [
{ color: pointColors.kiosk, id: "kiosk", name: "Городские киоски" },
{ color: pointColors.mfc, id: "mfc", name: "МФЦ" },
{ color: pointColors.library, id: "library", name: "Библиотеки" },
{ color: pointColors.dk, id: "dk", name: "Дома культуры и клубы" },
{ color: pointColors.sport, id: "sport", name: "Спортивные объекты" },
];
export function Legend() { export function Legend() {
return ( return (
<div className="absolute bottom-[20px] right-[20px] bg-white-background w-[250px] rounded-xl p-4 text-xs text-grey z-10"> <div className="absolute bottom-[20px] right-[20px] bg-white-background w-[250px] rounded-xl p-4 text-xs text-grey z-10">
<Title text={"Тип объекта размещения"} className={"text-center"} />
<Title <Title
text={"Размер кружка пропорционален востребованности точки"} text={"Категории объекта объекта размещения"}
className={"text-center"}
/>
<Title
text={"Размер кружка пропорционален прогнозному трафику"}
className={"text-center mb-1"} className={"text-center mb-1"}
classNameText={"lowercase"} classNameText={"lowercase"}
/> />
<div className="space-y-2"> <div className="space-y-2">
{types.map((type) => ( {Object.entries(pointColors).map(([label, color]) => (
<div className="flex gap-2 items-center" key={type.id}> <div className="flex gap-2 items-center" key={label}>
<span <span
className="rounded-xl w-3 h-3 inline-block" className="rounded-xl w-3 h-3 inline-block"
style={{ backgroundColor: type.color }} style={{ backgroundColor: color }}
/> />
<span>{type.name}</span> <span>{label}</span>
</div> </div>
))} ))}
</div> </div>

@ -42,3 +42,19 @@
.ant-table.ant-table-small tfoot > tr > td { .ant-table.ant-table-small tfoot > tr > td {
padding: 4px; padding: 4px;
} }
.ant-table-tbody > tr.ant-table-row-selected.scroll-row > td {
@apply bg-lime-200;
}
.ant-table-tbody > tr.ant-table-row.scroll-row > td {
@apply bg-lime-200;
}
.ant-table-tbody > tr.ant-table-row-selected.scroll-row:hover > td {
@apply bg-lime-200;
}
.ant-table-tbody > tr.ant-table-row.scroll-row:hover > td {
@apply bg-lime-200;
}

@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { Collapse, Empty, Table as AntdTable } from "antd"; import { Collapse, Empty, Table as AntdTable } from "antd";
import "./Table.css"; import "./Table.css";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@ -7,7 +7,8 @@ import parse from "wellknown";
import { useMap } from "react-map-gl"; import { useMap } from "react-map-gl";
import { usePointSelection } from "../../stores/usePointSelection"; import { usePointSelection } from "../../stores/usePointSelection";
import { useFilters } from "../../stores/useFilters"; import { useFilters } from "../../stores/useFilters";
import { useClickedPoint } from "../../stores/useClickedPoint"; import { useClickedPointLocationId } from "../../stores/useClickedPointLocationId";
import scrollIntoView from "scroll-into-view-if-needed";
const columns = [ const columns = [
{ {
@ -69,24 +70,20 @@ const columns = [
const PAGE_SIZE = 30; const PAGE_SIZE = 30;
export const Table = React.memo(({ height = 200 }) => { const useTableData = (page) => {
const { map } = useMap(); const [pageSize, setPageSize] = useState(PAGE_SIZE);
const tableRef = useRef(null);
const [page, setPage] = useState(1);
const { filters } = useFilters(); const { filters } = useFilters();
const { prediction, status, categories } = filters; const { prediction, status, categories } = filters;
const { include, selection, exclude } = usePointSelection(); const { selection } = usePointSelection();
const { point } = useClickedPoint(); const { clickedPointConfig } = useClickedPointLocationId();
const [finalData, setFinalData] = useState();
console.log(point);
const { data } = useQuery( const { data } = useQuery(
["table", page, filters], ["table", page, filters, selection],
async () => { async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
page, page,
page_size: PAGE_SIZE, page_size: pageSize,
"prediction_current[]": prediction, "prediction_current[]": prediction,
"status[]": status, "status[]": status,
"categories[]": categories, "categories[]": categories,
@ -103,6 +100,85 @@ export const Table = React.memo(({ height = 200 }) => {
{ keepPreviousData: true } { keepPreviousData: true }
); );
useEffect(() => {
if (!data) return;
setFinalData(data);
}, [data]);
const [shouldLoadClickedPoint, setShouldLoadClickedPoint] = useState(false);
useEffect(() => {
if (!data || clickedPointConfig === null) return;
const clickedPoint = data.results.find(
(item) => item.location_id === clickedPointConfig.locationId
);
if (clickedPoint) {
return;
}
setShouldLoadClickedPoint(true);
}, [data, clickedPointConfig]);
const {
data: remoteClickedPoint,
isInitialLoading,
isFetching,
} = useQuery(
["clicked-point", clickedPointConfig],
async () => {
const params = new URLSearchParams({
"location_ids[]": [clickedPointConfig.locationId],
});
const { data } = await api.get(
`/api/placement_points?${params.toString()}`
);
return data;
},
{
enabled: shouldLoadClickedPoint,
onSuccess: () => setShouldLoadClickedPoint(false),
}
);
useEffect(() => {
if (!remoteClickedPoint) return;
setPageSize((prevState) => prevState + 1);
setFinalData((prevState) => ({
...prevState,
count: prevState.count + 1,
results: [remoteClickedPoint.results[0], ...prevState.results],
}));
}, [remoteClickedPoint]);
useEffect(() => {
if (clickedPointConfig === null) {
setPageSize(PAGE_SIZE);
setFinalData(data);
}
}, [clickedPointConfig]);
return {
data: finalData,
pageSize,
isClickedPointLoading: isInitialLoading || isFetching,
};
};
export const Table = React.memo(({ height = 200 }) => {
const { map } = useMap();
const tableRef = useRef(null);
const [page, setPage] = useState(1);
const { selection, include, exclude } = usePointSelection();
const { clickedPointConfig } = useClickedPointLocationId();
const { data, pageSize, isClickedPointLoading } = useTableData(page);
const SCROLL = { const SCROLL = {
y: `${height}px`, y: `${height}px`,
x: "max-content", x: "max-content",
@ -110,13 +186,23 @@ export const Table = React.memo(({ height = 200 }) => {
const handlePageChange = useCallback((page) => setPage(page), []); const handlePageChange = useCallback((page) => setPage(page), []);
const getSelectedRowKeys = () => { const getSelectedRowKeys = useCallback(() => {
const ids = data?.results.map((item) => item.id) ?? []; const ids = data?.results.map((item) => item.id) ?? [];
const clickedPoint = data?.results.find(
(item) => item.location_id === clickedPointConfig?.locationId
);
const inExcludedList = (id) => selection.excluded.has(id);
const shouldNotSelect = (id) =>
id === clickedPoint?.id && clickedPointConfig?.shouldSelect === false;
return [ return [
...ids.filter((id) => !selection.excluded.has(id)), ...ids.filter((id) => {
return !inExcludedList(id) && !shouldNotSelect(id);
}),
...selection.included, ...selection.included,
]; ];
}; }, [data, clickedPointConfig, selection]);
const rowSelection = { const rowSelection = {
selectedRowKeys: getSelectedRowKeys(), selectedRowKeys: getSelectedRowKeys(),
@ -128,8 +214,18 @@ export const Table = React.memo(({ height = 200 }) => {
exclude(id); exclude(id);
} }
}, },
hideSelectAll: true,
}; };
useEffect(() => {
if (clickedPointConfig === null || isClickedPointLoading) return;
const row = document.querySelector(".scroll-row");
if (row) {
scrollIntoView(row, { behavior: "smooth" });
}
}, [clickedPointConfig, data]);
return ( return (
<div className="w-screen"> <div className="w-screen">
<Collapse> <Collapse>
@ -142,8 +238,7 @@ export const Table = React.memo(({ height = 200 }) => {
locale={{ emptyText: <Empty description="Нет данных" /> }} locale={{ emptyText: <Empty description="Нет данных" /> }}
// loading={isLoading} // loading={isLoading}
pagination={{ pagination={{
pageSize: PAGE_SIZE, pageSize,
hideOnSinglePage: true,
current: page, current: page,
onChange: handlePageChange, onChange: handlePageChange,
total: data?.count, total: data?.count,
@ -172,6 +267,11 @@ export const Table = React.memo(({ height = 200 }) => {
}; };
}} }}
rowSelection={rowSelection} rowSelection={rowSelection}
rowClassName={(record) =>
record.location_id === clickedPointConfig?.locationId
? "scroll-row"
: ""
}
/> />
</Collapse.Panel> </Collapse.Panel>
</Collapse> </Collapse>

@ -1,18 +0,0 @@
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
const store = (set) => ({
point: null,
setClickedPoint: (point) => {
set((state) => {
if (!point) {
state.point = null;
return state;
}
state.point = point;
});
},
});
export const useClickedPoint = create(immer(store));

@ -0,0 +1,21 @@
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
const store = (set) => ({
clickedPointConfig: null,
setClickedPointConfig: (locationId, shouldSelect = true) => {
set((state) => {
if (locationId === null) {
state.clickedPointConfig = null;
return state;
}
state.clickedPointConfig = {
locationId,
shouldSelect,
};
});
},
});
export const useClickedPointLocationId = create(immer(store));

@ -914,6 +914,11 @@ compute-scroll-into-view@^1.0.20:
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz#1768b5522d1172754f5d0c9b02de3af6be506a43" resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz#1768b5522d1172754f5d0c9b02de3af6be506a43"
integrity sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg== integrity sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==
compute-scroll-into-view@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.0.0.tgz#95d2f2f4653e7edda74dd1e38edaaa897918e0f0"
integrity sha512-Yk1An4qzo5++Cu6peT9PsmRKIU8tALpmdoE09n//AfGQFcPfx21/tMGMsmKYmLJWaBJrGOJ5Jz5hoU+7cZZUWQ==
concat-stream@~1.5.0: concat-stream@~1.5.0:
version "1.5.2" version "1.5.2"
resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.5.2.tgz#708978624d856af41a5a741defdd261da752c266" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.5.2.tgz#708978624d856af41a5a741defdd261da752c266"
@ -2388,6 +2393,13 @@ scroll-into-view-if-needed@^2.2.25:
dependencies: dependencies:
compute-scroll-into-view "^1.0.20" compute-scroll-into-view "^1.0.20"
scroll-into-view-if-needed@^3.0.6:
version "3.0.6"
resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.0.6.tgz#2c803a509c1036bc4a9c009fecc5c145f87e47cf"
integrity sha512-x+CW0kOzlFNOnseF0DBr0AJ5m+TgGmSOdEZwyiZW0gV87XBvxQKw5A8DvFFgabznA68XqLgVX+PwPX8OzsFvRA==
dependencies:
compute-scroll-into-view "^3.0.0"
semver@^5.6.0: semver@^5.6.0:
version "5.7.1" version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"

Loading…
Cancel
Save