Synchronize map and table

dev
Platon Yasev 3 years ago
parent cbe5aebb41
commit 0e166a490d

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

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

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

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

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

@ -1,31 +1,26 @@
import { Title } from "../../components/Title";
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() {
return (
<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
text={"Размер кружка пропорционален востребованности точки"}
text={"Категории объекта объекта размещения"}
className={"text-center"}
/>
<Title
text={"Размер кружка пропорционален прогнозному трафику"}
className={"text-center mb-1"}
classNameText={"lowercase"}
/>
<div className="space-y-2">
{types.map((type) => (
<div className="flex gap-2 items-center" key={type.id}>
{Object.entries(pointColors).map(([label, color]) => (
<div className="flex gap-2 items-center" key={label}>
<span
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>

@ -42,3 +42,19 @@
.ant-table.ant-table-small tfoot > tr > td {
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 "./Table.css";
import { useQuery } from "@tanstack/react-query";
@ -7,7 +7,8 @@ import parse from "wellknown";
import { useMap } from "react-map-gl";
import { usePointSelection } from "../../stores/usePointSelection";
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 = [
{
@ -69,24 +70,20 @@ const columns = [
const PAGE_SIZE = 30;
export const Table = React.memo(({ height = 200 }) => {
const { map } = useMap();
const tableRef = useRef(null);
const [page, setPage] = useState(1);
const useTableData = (page) => {
const [pageSize, setPageSize] = useState(PAGE_SIZE);
const { filters } = useFilters();
const { prediction, status, categories } = filters;
const { include, selection, exclude } = usePointSelection();
const { point } = useClickedPoint();
console.log(point);
const { selection } = usePointSelection();
const { clickedPointConfig } = useClickedPointLocationId();
const [finalData, setFinalData] = useState();
const { data } = useQuery(
["table", page, filters],
["table", page, filters, selection],
async () => {
const params = new URLSearchParams({
page,
page_size: PAGE_SIZE,
page_size: pageSize,
"prediction_current[]": prediction,
"status[]": status,
"categories[]": categories,
@ -103,6 +100,85 @@ export const Table = React.memo(({ height = 200 }) => {
{ 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 = {
y: `${height}px`,
x: "max-content",
@ -110,13 +186,23 @@ export const Table = React.memo(({ height = 200 }) => {
const handlePageChange = useCallback((page) => setPage(page), []);
const getSelectedRowKeys = () => {
const getSelectedRowKeys = useCallback(() => {
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 [
...ids.filter((id) => !selection.excluded.has(id)),
...ids.filter((id) => {
return !inExcludedList(id) && !shouldNotSelect(id);
}),
...selection.included,
];
};
}, [data, clickedPointConfig, selection]);
const rowSelection = {
selectedRowKeys: getSelectedRowKeys(),
@ -128,8 +214,18 @@ export const Table = React.memo(({ height = 200 }) => {
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 (
<div className="w-screen">
<Collapse>
@ -142,8 +238,7 @@ export const Table = React.memo(({ height = 200 }) => {
locale={{ emptyText: <Empty description="Нет данных" /> }}
// loading={isLoading}
pagination={{
pageSize: PAGE_SIZE,
hideOnSinglePage: true,
pageSize,
current: page,
onChange: handlePageChange,
total: data?.count,
@ -172,6 +267,11 @@ export const Table = React.memo(({ height = 200 }) => {
};
}}
rowSelection={rowSelection}
rowClassName={(record) =>
record.location_id === clickedPointConfig?.locationId
? "scroll-row"
: ""
}
/>
</Collapse.Panel>
</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"
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:
version "1.5.2"
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:
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:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"

Loading…
Cancel
Save