Calculate rating

dev
Platon Yasev 3 years ago
parent 52d53cceec
commit 8f3bd0eab8

@ -9,8 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
"@turf/bbox": "^6.5.0",
"@turf/helpers": "^6.5.0",
"@watergis/maplibre-gl-export": "^1.3.7",
"antd": "^4.23.6",
"axios": "^1.1.3",
"immer": "^9.0.16",
"mapbox-gl": "npm:empty-npm-package@1.0.0",
"maplibre-gl": "^2.4.0",

@ -2,18 +2,60 @@ import { Layer, Source } from "react-map-gl";
import { gridLayer } from "./layers-config";
import { useGridSize } from "../stores/useGridSize";
import { useLayersVisibility } from "../stores/useLayersVisibility";
import { useFactors } from "../stores/useFactors";
import { useMemo } from "react";
export const Grid = ({ rate }) => {
const getWeightedValueExpression = (factor, weight) => {
return ["*", ["to-number", ["get", factor]], weight];
};
export const getRateExpression = (factorWeights) => {
const weightSum = Object.entries(factorWeights).reduce(
(acc, [_factor, weight]) => {
acc += weight;
return acc;
},
0
);
const weightedValuesSum = Object.entries(factorWeights).reduce(
(acc, [factor, weight]) => {
acc.push(getWeightedValueExpression(factor, weight));
return acc;
},
["+"]
);
return ["/", weightedValuesSum, weightSum];
};
export const Grid = ({ rate: rateRange }) => {
const { gridSize } = useGridSize();
const {
isVisible: { grid },
} = useLayersVisibility();
const { factors } = useFactors();
const rate = useMemo(() => getRateExpression(factors), [factors]);
const filter = useMemo(() => {
return ["all", [">=", rate, rateRange[0]], ["<=", rate, rateRange[1]]];
}, [rate, rateRange]);
const filter = [
"all",
[">=", ["get", "rate"], rate[0]],
["<=", ["get", "rate"], rate[1]],
];
const paintConfig = {
...gridLayer.paint,
"fill-color": [
"interpolate",
["linear"],
rate,
0,
"rgb(204,34,34)",
25,
"rgb(255,221,52)",
40,
"rgb(30,131,42)",
],
};
return (
<>
@ -21,18 +63,19 @@ export const Grid = ({ rate }) => {
id="grid3"
type="vector"
tiles={[
`https://postamates.spatiality.website/martin/public.net3/{z}/{x}/{y}.pbf`,
`https://postamates.spatiality.website/martin/public.net_3/{z}/{x}/{y}.pbf`,
]}
>
<Layer
{...gridLayer}
id={"grid3"}
source={"grid3"}
source-layer={"public.net3"}
source-layer={"public.net_3"}
layout={{
...gridLayer.layout,
visibility: grid && gridSize === "net3" ? "visible" : "none",
visibility: grid && gridSize === "net_3" ? "visible" : "none",
}}
paint={paintConfig}
filter={filter}
/>
</Source>
@ -40,38 +83,40 @@ export const Grid = ({ rate }) => {
id="grid4"
type="vector"
tiles={[
`https://postamates.spatiality.website/martin/public.net4/{z}/{x}/{y}.pbf`,
`https://postamates.spatiality.website/martin/public.net_4/{z}/{x}/{y}.pbf`,
]}
>
<Layer
{...gridLayer}
id={"grid4"}
source={"grid4"}
source-layer={"public.net4"}
source-layer={"public.net_4"}
layout={{
...gridLayer.layout,
visibility: grid && gridSize === "net4" ? "visible" : "none",
visibility: grid && gridSize === "net_4" ? "visible" : "none",
}}
filter={filter}
paint={paintConfig}
/>
</Source>
<Source
id="grid5"
type="vector"
tiles={[
`https://postamates.spatiality.website/martin/public.net5/{z}/{x}/{y}.pbf`,
`https://postamates.spatiality.website/martin/public.net_5/{z}/{x}/{y}.pbf`,
]}
>
<Layer
{...gridLayer}
id={"grid5"}
source={"grid5"}
source-layer={"public.net5"}
source-layer={"public.net_5"}
layout={{
...gridLayer.layout,
visibility: grid && gridSize === "net5" ? "visible" : "none",
visibility: grid && gridSize === "net_5" ? "visible" : "none",
}}
filter={filter}
paint={paintConfig}
/>
</Source>
</>

@ -2,7 +2,6 @@ import maplibregl from "maplibre-gl";
import Map, { useControl } from "react-map-gl";
import { useRef, useState } from "react";
import { Sidebar } from "../modules/Sidebar/Sidebar";
import { pointLayer } from "./layers-config";
import { Layers } from "./Layers";
import { MapPopup } from "./Popup";
import { MaplibreExportControl } from "@watergis/maplibre-gl-export";
@ -113,7 +112,14 @@ export const MapComponent = () => {
}}
dragRotate={false}
ref={mapRef}
interactiveLayerIds={[pointLayer.id, "grid3", "grid4", "grid5"]}
interactiveLayerIds={[
"point3",
"point4",
"point5",
"grid3",
"grid4",
"grid5",
]}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}

@ -2,35 +2,96 @@ import { Layer, Source } from "react-map-gl";
import { pointLayer } from "./layers-config";
import { useLayersVisibility } from "../stores/useLayersVisibility";
import { useActiveTypes } from "../stores/useActiveTypes";
import { useGridSize } from "../stores/useGridSize";
import { useFactors } from "../stores/useFactors";
import { useMemo } from "react";
import { getRateExpression } from "./Grid";
export const Points = ({ rate }) => {
export const Points = ({ rate: rateRange }) => {
const { gridSize } = useGridSize();
const { isVisible } = useLayersVisibility();
const { activeTypes } = useActiveTypes();
const { factors } = useFactors();
const getFilter = () => {
if (activeTypes.length) {
return ["in", "category", ...activeTypes];
} else {
return ["all"];
}
};
const rate = useMemo(() => getRateExpression(factors), [factors]);
const filter = useMemo(() => {
let result = [
"all",
[">=", rate, rateRange[0]],
["<=", rate, rateRange[1]],
];
// if (activeTypes.length) {
// result = ["all", result, ["in", "category", ...activeTypes]];
// }
console.log(result);
return result;
}, [rate, rateRange, activeTypes]);
return (
<Source
id="points"
type="vector"
tiles={[
"https://postamates.spatiality.website/martin/public.point3/{z}/{x}/{y}.pbf",
]}
>
<Layer
{...pointLayer}
layout={{
...pointLayer.layout,
visibility: isVisible.points ? "visible" : "none",
}}
filter={getFilter()}
/>
</Source>
<>
<Source
id="point3"
type="vector"
tiles={[
"https://postamates.spatiality.website/martin/public.point3/{z}/{x}/{y}.pbf",
]}
>
<Layer
{...pointLayer}
id={"point3"}
source={"point3"}
source-layer={"public.point3"}
layout={{
...pointLayer.layout,
visibility:
isVisible.points && gridSize === "net_3" ? "visible" : "none",
}}
filter={filter}
/>
</Source>
<Source
id="point4"
type="vector"
tiles={[
"https://postamates.spatiality.website/martin/public.point4/{z}/{x}/{y}.pbf",
]}
>
<Layer
{...pointLayer}
id={"point4"}
source={"point4"}
source-layer={"public.point4"}
layout={{
...pointLayer.layout,
visibility:
isVisible.points && gridSize === "net_4" ? "visible" : "none",
}}
filter={filter}
/>
</Source>
<Source
id="point5"
type="vector"
tiles={[
"https://postamates.spatiality.website/martin/public.point5/{z}/{x}/{y}.pbf",
]}
>
<Layer
{...pointLayer}
id={"point5"}
source={"point5"}
source-layer={"public.point5"}
layout={{
...pointLayer.layout,
visibility:
isVisible.points && gridSize === "net_5" ? "visible" : "none",
}}
filter={filter}
/>
</Source>
</>
);
};

@ -2,6 +2,26 @@ import { Popup } from "react-map-gl";
import { Col, Row } from "antd";
import { twMerge } from "tailwind-merge";
import { TYPE_MAPPER } from "../config";
import { useFactors } from "../stores/useFactors";
const getRate = (featureProps, weights) => {
const weightedSum = Object.entries(weights).reduce(
(acc, [factor, weight]) => {
const value = Number(featureProps[factor]);
const weightedValue = value * weight;
acc += weightedValue;
return acc;
},
0
);
const weightSum = Object.values(weights).reduce((acc, weight) => {
acc += weight;
return acc;
}, 0);
return weightedSum / weightSum;
};
const pointConfig = [
{
@ -9,27 +29,34 @@ const pointConfig = [
name: "Тип",
formatter: (value) => TYPE_MAPPER[value],
},
// {
// field: "msk_ao",
// name: "АО",
// },
// {
// field: "msk_rayon",
// name: "Район",
// },
{
field: "msk_ao",
name: "АО",
},
{
field: "msk_rayon",
name: "Район",
name: "Востребованность, у.е.",
formatter: (value) => Math.round(value),
},
];
const gridConfig = [
{
field: "rate",
name: "Востребованность, у.е.",
formatter: (value) => Math.round(value),
},
];
export const MapPopup = ({ feature, lat, lng, onClose }) => {
const isPoint = feature.geometry.type === "Point";
const config = isPoint ? pointConfig : gridConfig;
const layout = isPoint ? [10, 14] : [20, 4];
const layout = isPoint
? { width: 300, keyCol: 15, valueCol: 9 }
: { width: 250, keyCol: 20, valueCol: 4 };
const { factors } = useFactors();
return (
<Popup
@ -39,16 +66,18 @@ export const MapPopup = ({ feature, lat, lng, onClose }) => {
onClose={onClose}
closeOnClick={false}
>
<div className={"min-w-[200px]"}>
<div className={`min-w-[250px]`}>
{config.map((item) => {
const value = feature.properties[item.field];
const value = item.field
? feature.properties[item.field]
: getRate(feature.properties, factors);
return (
<Row className={twMerge("p-1")} key={item.field}>
<Col span={layout[0]} className={"font-semibold"}>
<Row className={twMerge("p-1")} key={item.field ?? "rate"}>
<Col span={layout.keyCol} className={"font-semibold"}>
{item.name}
</Col>
<Col span={layout[1]}>
<Col span={layout.valueCol}>
{item.formatter ? item.formatter(value) : value}
</Col>
</Row>

@ -13,10 +13,7 @@ const pointColors = {
};
export const pointLayer = {
id: "points",
type: "circle",
source: "points",
"source-layer": "public.point3",
layout: {},
paint: {
"circle-color": [
@ -45,18 +42,7 @@ export const gridLayer = {
type: "fill",
layout: {},
paint: {
"fill-color": [
"interpolate",
["linear"],
["get", "rate"],
0,
"rgb(204,34,34)",
5,
"rgb(255,221,52)",
10,
"rgb(30,131,42)",
],
"fill-opacity": 0.3,
"fill-opacity": 0.5,
"fill-outline-color": "transparent",
},
};

@ -30,6 +30,7 @@ export const SliderComponent = ({
min = 0,
max = 100,
range = false,
step = 1,
}) => {
const fullRangeMarks = {
[min]: <Mark value={min} />,
@ -74,6 +75,7 @@ export const SliderComponent = ({
onAfterChange={handleAfterChange}
min={min}
max={max}
step={step}
/>
</div>
);

@ -1,3 +1,5 @@
import axios from "axios";
export const TYPE_MAPPER = {
kiosk: "Городской киоск",
mfc: "МФЦ",
@ -5,3 +7,28 @@ export const TYPE_MAPPER = {
dk: "Дом культуры и отдыха",
sport: "Спортивный объект",
};
export const factorsNameMapper = {
people: "Численность населения в 2021 г.",
people2025: "Численность населения в 2025 г. (прогноз)",
stops_ot: "Остановки общественного транспорта",
routes_ot: "Маршруты общетвенного транспорта",
in_metro: "Входы в ближайшее метро в месяц",
out_metro: "Выходы из ближайшего метро в месяц",
tc: "Тогровые центры",
empls: "Рабочие места",
walkers: "Трафик населения",
schools: "Школы и детские сады",
parking: "Парковочные места",
pvz: "Пункты выдачи заказов",
gov_place: "Рекомендованные пункты размещения постаматов",
bike_park: "Городская аренда велосипедов, шт.",
products: "Продовольственные магазины",
nonprod: "Непродовольственные магазины",
service: "Пункты оказания бытовых услуг",
vuz: "ВУЗы и техникумы",
};
export const api = axios.create({
baseURL: "https://postamates.spatiality.website",
});

@ -2,6 +2,11 @@
@tailwind components;
@tailwind utilities;
.mapboxgl-popup,
.maplibregl-popup {
@apply !max-w-[400px];
}
.mapboxgl-popup-content,
.maplibregl-popup-content {
@apply bg-grey-light shadow-lg rounded-md;
@ -13,7 +18,7 @@
}
.ant-popover-inner {
@apply bg-white-background rounded-xl;
@apply bg-white-background rounded-xl max-h-[calc(100vh-100px)] overflow-y-auto;
}
.mapboxgl-ctrl-group,

@ -3,9 +3,9 @@ import { Title } from "../../components/Title";
import { useGridSize } from "../../stores/useGridSize";
const options = [
{ label: "3 мин", value: "net3" },
{ label: "4 мин", value: "net4" },
{ label: "5 мин", value: "net5" },
{ label: "3 мин", value: "net_3" },
{ label: "4 мин", value: "net_4" },
{ label: "5 мин", value: "net_5" },
];
export const GridSizeSelect = () => {

@ -11,7 +11,7 @@ export const RatingSlider = () => {
title={"Востребованность постамата, у.e."}
value={rate}
onAfterChange={handleAfterChange}
max={10}
max={45}
range
/>
);

@ -1,34 +1,63 @@
import { TreeSelect } from "antd";
import { Title } from "../../components/Title";
import { useRegion } from "../../stores/useRegion";
import { useEffect, useMemo, useState } from "react";
import { api } from "../../config";
import { useMap } from "react-map-gl";
import getBbox from "@turf/bbox";
import { polygon as getPolygon } from "@turf/helpers";
const { TreeNode } = TreeSelect;
const mockRegions = [
{
id: "tsao",
name: "Центральный (ЦАО)",
children: [
{ id: "arbat", name: "Арбат" },
{ id: "hamovniki", name: "Хамовники" },
],
},
{
id: "yuao",
name: "Южный (ЮАО)",
children: [
{ id: "danilovsk", name: "Даниловский" },
{ id: "nagorn", name: "Нагорный" },
],
},
];
const normalizeRegions = (rawRegions) => {
if (!rawRegions) return {};
return rawRegions.reduce((acc, raw) => {
acc[`ao-${raw.id}`] = raw;
if (raw.children) {
raw.children.forEach((child) => {
acc[`rayon-${child.id}`] = child;
});
}
return acc;
}, {});
};
export const RegionSelect = () => {
const { current: map } = useMap();
const { region, setRegion } = useRegion();
const [data, setData] = useState([]);
const normalizedData = useMemo(() => normalizeRegions(data), [data]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const getRegions = async () => {
setLoading(true);
try {
const response = await api.get("/api/ao_and_rayons");
setData(response.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
getRegions();
}, []);
const onChange = (value) => {
console.log(value);
const selectedRegion = normalizedData[value];
const polygon = getPolygon(selectedRegion.geometry[0]);
const bbox = getBbox(polygon);
setRegion(value);
map.fitBounds([
[bbox[0], bbox[1]], // southwestern corner of the bounds
[bbox[2], bbox[3]], // northeastern corner of the bounds
]);
};
return (
@ -41,16 +70,25 @@ export const RegionSelect = () => {
dropdownStyle={{ maxHeight: 400, overflow: "auto" }}
placeholder="Выберите АО или район"
allowClear
treeDefaultExpandAll
treeDefaultExpandAll={false}
onChange={onChange}
loading={loading}
// treeNodeFilterProp="name"
// filterTreeNode={true}
>
{mockRegions.map((parent) => {
{data?.map((parent) => {
return (
<TreeNode key={parent.id} value={parent.id} title={parent.name}>
<TreeNode
key={`ao-${parent.id}`}
value={`ao-${parent.id}`}
title={parent.name}
>
{parent.children?.map((child) => (
<TreeNode key={child.id} value={child.id} title={child.name} />
<TreeNode
key={`rayon-${child.id}`}
value={`rayon-${child.id}`}
title={child.name}
/>
))}
</TreeNode>
);

@ -1,13 +1,29 @@
import { SliderComponent as Slider } from "../../components/SliderComponent";
import { Button } from "antd";
import { useFactors } from "../../stores/useFactors";
import { factorsNameMapper } from "../../config";
export const Settings = () => {
const { factors, setWeight } = useFactors();
const handleAfterChange = (factor, value) => {
setWeight(factor, value);
};
return (
<div className={"space-y-2 min-w-[300px]"}>
<Slider title={"Количество жителей"} value={50} />
<Slider title={"Количество станций метро"} value={50} />
<Slider title={"Количество существующих постаматов"} value={50} />
<Slider title={"Whatever"} value={50} />
{Object.entries(factors).map(([field, value]) => {
return (
<Slider
title={factorsNameMapper[field]}
value={value}
key={field}
max={1}
step={0.01}
onAfterChange={(value) => handleAfterChange(field, value)}
/>
);
})}
<div>
<Button type="primary" block className={"mt-2"}>
Рассчитать

@ -10,7 +10,7 @@ import { Settings } from "./Settings";
export const Sidebar = () => {
return (
<div className="absolute top-[10px] right-[10px] bg-white-background w-[300px] rounded-xl p-3">
<div className="absolute top-[10px] right-[10px] bg-white-background w-[300px] rounded-xl p-3 max-h-[calc(100vh-30px)] overflow-y-auto">
<Popover
placement="leftTop"
title={"Веса факторов"}

@ -0,0 +1,20 @@
import create from "zustand";
import { immer } from "zustand/middleware/immer";
import { factorsNameMapper } from "../config";
const DEFAULT_WEIGHT = 0.5;
const INITIAL_STATE = Object.keys(factorsNameMapper).reduce((acc, field) => {
acc[field] = DEFAULT_WEIGHT;
return acc;
}, {});
const store = (set) => ({
factors: INITIAL_STATE,
setWeight: (factor, value) =>
set((state) => {
state.factors[factor] = value;
}),
});
export const useFactors = create(immer(store));

@ -2,7 +2,7 @@ import create from "zustand";
import { immer } from "zustand/middleware/immer";
const store = (set) => ({
gridSize: "net5",
gridSize: "net_5",
setGridSize: (value) =>
set((state) => {
state.gridSize = value;

@ -2,7 +2,7 @@ import create from "zustand";
import { immer } from "zustand/middleware/immer";
const store = (set) => ({
rate: [0, 10],
rate: [0, 45],
setRate: (value) =>
set((state) => {
state.rate = value;

@ -422,6 +422,26 @@
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==
"@turf/bbox@^6.5.0":
version "6.5.0"
resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-6.5.0.tgz#bec30a744019eae420dac9ea46fb75caa44d8dc5"
integrity sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw==
dependencies:
"@turf/helpers" "^6.5.0"
"@turf/meta" "^6.5.0"
"@turf/helpers@^6.5.0":
version "6.5.0"
resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-6.5.0.tgz#f79af094bd6b8ce7ed2bd3e089a8493ee6cae82e"
integrity sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==
"@turf/meta@^6.5.0":
version "6.5.0"
resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-6.5.0.tgz#b725c3653c9f432133eaa04d3421f7e51e0418ca"
integrity sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA==
dependencies:
"@turf/helpers" "^6.5.0"
"@types/geojson@*", "@types/geojson@^7946.0.10":
version "7946.0.10"
resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249"
@ -677,6 +697,15 @@ autoprefixer@^10.4.13:
picocolors "^1.0.0"
postcss-value-parser "^4.2.0"
axios@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.1.3.tgz#8274250dada2edf53814ed7db644b9c2866c1e35"
integrity sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==
dependencies:
follow-redirects "^1.15.0"
form-data "^4.0.0"
proxy-from-env "^1.1.0"
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
@ -1242,6 +1271,11 @@ fill-range@^7.0.1:
dependencies:
to-regex-range "^5.0.1"
follow-redirects@^1.15.0:
version "1.15.2"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13"
integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
form-data@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
@ -1994,6 +2028,11 @@ protocol-buffers-schema@^3.3.1:
resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz#77bc75a48b2ff142c1ad5b5b90c94cd0fa2efd03"
integrity sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
prr@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"

Loading…
Cancel
Save