Преглед изворни кода

feat:添加农情档案页面新样式

wangsisi пре 6 дана
родитељ
комит
d3789abaf7

BIN
src/assets/img/map/tool-1.png


BIN
src/assets/img/map/tool-2.png


BIN
src/assets/img/map/tool-3.png


BIN
src/assets/img/map/tool-4.png


BIN
src/assets/img/map/tool-5.png


+ 24 - 2
src/i18n/messages.js

@@ -116,11 +116,22 @@ export default {
             legendZone: "管理分区",
             legendGrowth: "长势异常",
             legendPest: "病虫害异常",
+            scaleNormal: "普通",
+            scaleGood: "良好",
+            scaleExcellent: "优秀",
             loading: "加载中...",
             noRecord: "暂无记录",
-            tabPhenology: "物候记录",
+            noData: "暂无数据",
+            tabPhenology: "物候进程",
             tabAbnormal: "异常记录",
             tabFarming: "农事记录",
+            tabAgriRecord: "农情记录",
+            tabRemoteSensing: "时序遥感指标",
+            remoteSensingChartTitle: "时序遥感指数",
+            remoteSensingLegendZone: "区域一指数曲线",
+            remoteSensingLegendStandard: "标准指数曲线",
+            timeAxisLabel: "时间",
+            remoteSensingValuePlaceholder: "数字",
             recordFruitExpand: "果园出现果实迅速膨大,占比",
             recordFruitSet: "果园出现谢花坐果,占比",
             recordFullBloom: "果园进入盛花期",
@@ -288,11 +299,22 @@ export default {
             legendZone: "Management Zone",
             legendGrowth: "Growth Abnormality",
             legendPest: "Pest & Disease",
+            scaleNormal: "Normal",
+            scaleGood: "Good",
+            scaleExcellent: "Excellent",
             loading: "Loading...",
             noRecord: "No records",
-            tabPhenology: "Phenology",
+            noData: "No data",
+            tabPhenology: "Phenological Process",
             tabAbnormal: "Abnormalities",
             tabFarming: "Farm Work",
+            tabAgriRecord: "Crop Records",
+            tabRemoteSensing: "Time-series Remote Sensing",
+            remoteSensingChartTitle: "Time-series Remote Sensing Index",
+            remoteSensingLegendZone: "Zone 1 Index Curve",
+            remoteSensingLegendStandard: "Standard Index Curve",
+            timeAxisLabel: "Time",
+            remoteSensingValuePlaceholder: "Number",
             recordFruitExpand: "Rapid fruit expansion in orchard, ",
             recordFruitSet: "Flower drop and fruit set in orchard, ",
             recordFullBloom: "Orchard in full bloom",

+ 188 - 20
src/views/old_mini/agri_file/components/fileFloat.vue

@@ -4,22 +4,53 @@
         :anchors="anchors">
         <div class="file-float-content">
             <div class="float-tabs">
-                <div class="tab-active-bg" :style="activeBgStyle"></div>
+                <div class="tab-active-bg" :style="primaryActiveBgStyle"></div>
                 <div v-for="(item, index) in floatTabLabels" :key="item.value" class="tab-item"
-                    @click="changeTab(item, index)" :class="{ 'tab-item-active': activeTab === index }">
+                    @click="changePrimaryTab(index)" :class="{ 'tab-item-active': activeTab === index }">
                     {{ item.title }}
                 </div>
             </div>
-
             <div class="tab-content-group" v-show="height !== anchors[0]">
-                <div class="tab-loading" v-if="loading">{{ t('agriFile.loading') }}</div>
-                <div class="tab-empty" v-else-if="currentList.length === 0">{{ t('agriFile.noRecord') }}</div>
-                <div v-else class="tab-content-item" v-for="item in displayList" :key="item.id">
-                    <div class="time-tag">{{ item.time }}</div>
-                    <div class="item-info">
-                        {{ item.recordText }}
-                        <span class="blue-text">{{ item.ratio }}{{ item.showRatio ? '%' : '' }}</span>
+                <template v-if="isAgriRecordTab">
+                    <div class="float-sub-tabs">
+                        <div v-for="(item, index) in agriSubTabLabels" :key="item.value" class="sub-tab-item"
+                            :class="{ 'sub-tab-item-active': activeSubTab === index }" @click="changeSubTab(index)">
+                            {{ item.title }}
+                        </div>
+                    </div>
+                    <div class="tab-loading" v-if="loading">{{ t('agriFile.loading') }}</div>
+                    <div class="tab-empty" v-else-if="currentList.length === 0">{{ t('agriFile.noData') }}</div>
+                    <div v-else class="tab-content-item" v-for="item in displayList" :key="item.id">
+                        <div class="time-tag">{{ item.time }}</div>
+                        <div class="item-info">
+                            {{ item.recordText }}
+                            <span class="blue-text">{{ item.ratio }}{{ item.showRatio ? '%' : '' }}</span>
+                        </div>
                     </div>
+                </template>
+                <div v-else-if="isRemoteSensingTab" class="remote-sensing-chart">
+                    <div class="remote-sensing-chart__header">
+                        <span class="remote-sensing-chart__title">{{ t('agriFile.remoteSensingChartTitle') }}</span>
+                        <div class="remote-sensing-chart__legend">
+                            <div
+                                v-for="item in remoteSensingLegendItems"
+                                :key="item.key"
+                                class="remote-sensing-chart__legend-item"
+                            >
+                                <span
+                                    class="remote-sensing-chart__legend-line"
+                                    :style="{ background: item.color }"
+                                ></span>
+                                <span class="remote-sensing-chart__legend-text">{{ item.label }}</span>
+                            </div>
+                        </div>
+                    </div>
+                    <div class="tab-loading" v-if="loading">{{ t('agriFile.loading') }}</div>
+                    <remote-sensing-chart
+                        v-else-if="hasRemoteSensingData"
+                        :chart-data="remoteSensingChartData"
+                    />
+                    <div class="tab-empty" v-else>{{ t('agriFile.noData') }}</div>
                 </div>
             </div>
         </div>
@@ -31,6 +62,7 @@ import { useI18n } from "@/i18n";
 import { RECORD_KEY_MAP } from "@/i18n/recordTextMap";
 import { FloatingPanel } from 'vant';
 import { computed, ref, watch } from 'vue';
+import remoteSensingChart from './remoteSensingChart.vue';
 
 const { t } = useI18n();
 
@@ -43,13 +75,21 @@ const props = defineProps({
         type: Number,
         default: 0,
     },
+    activeSubTab: {
+        type: Number,
+        default: 0,
+    },
     loading: {
         type: Boolean,
         default: false,
     },
+    remoteSensingData: {
+        type: Array,
+        default: () => [],
+    },
 });
 
-const emit = defineEmits(["update:activeTab"]);
+const emit = defineEmits(["update:activeTab", "update:activeSubTab"]);
 
 const anchors = [
     130,
@@ -58,34 +98,96 @@ const anchors = [
 ];
 const height = ref(anchors[0]);
 
+const AGRI_SUB_TAB_KEYS = ["phenology", "abnormal", "farming"];
+
 const floatTabLabels = computed(() => [
+    { title: t("agriFile.tabAgriRecord"), value: "agriRecord" },
+    { title: t("agriFile.tabRemoteSensing"), value: "remoteSensing" },
+]);
+
+const agriSubTabLabels = computed(() => [
     { title: t("agriFile.tabPhenology"), value: "phenology" },
     { title: t("agriFile.tabAbnormal"), value: "abnormal" },
     { title: t("agriFile.tabFarming"), value: "farming" },
 ]);
 
 const currentList = ref([]);
-const activeTabValue = computed(() => floatTabLabels.value[props.activeTab]?.value || "phenology");
+
+const isAgriRecordTab = computed(() => floatTabLabels.value[props.activeTab]?.value === "agriRecord");
+
+const isRemoteSensingTab = computed(() => floatTabLabels.value[props.activeTab]?.value === "remoteSensing");
+
+const REMOTE_SENSING_LEGEND_CONFIG = [
+    { key: "zone", labelKey: "agriFile.remoteSensingLegendZone", color: "#2199F8" },
+    { key: "standard", labelKey: "agriFile.remoteSensingLegendStandard", color: "#9FD1FA" },
+];
+
+const remoteSensingLegendItems = computed(() =>
+    REMOTE_SENSING_LEGEND_CONFIG.map(({ key, labelKey, color }) => ({
+        key,
+        label: t(labelKey),
+        color,
+    }))
+);
+
+const remoteSensingChartData = computed(() => {
+    const raw = props.remoteSensingData;
+    if (!raw) return null;
+
+    if (!Array.isArray(raw) && raw.zoneSeries?.length) {
+        return raw;
+    }
+
+    if (!Array.isArray(raw) || raw.length === 0) {
+        return null;
+    }
+
+    const highlightIndex = raw.findIndex((item) => item.highlight);
+
+    return {
+        timeLabels: raw.map((item) => item.time ?? item.label ?? t("agriFile.timeAxisLabel")),
+        zoneSeries: raw.map((item) => Number(item.zone ?? item.zoneValue ?? item.value)),
+        standardSeries: raw.map((item) => Number(item.standard ?? item.standardValue ?? item.stdValue)),
+        highlightIndex: highlightIndex >= 0 ? highlightIndex : undefined,
+    };
+});
+
+const hasRemoteSensingData = computed(() => {
+    const data = remoteSensingChartData.value;
+    return Boolean(data?.zoneSeries?.length && data?.standardSeries?.length);
+});
+
+const activeSubTabValue = computed(
+    () => agriSubTabLabels.value[props.activeSubTab]?.value || AGRI_SUB_TAB_KEYS[0]
+);
 
 const displayList = computed(() =>
     currentList.value.map((item) => ({
         ...item,
         recordText: t(RECORD_KEY_MAP[item.record] || item.record),
-        showRatio: activeTabValue.value !== "farming" && String(item.ratio ?? "").length > 0,
+        showRatio: activeSubTabValue.value !== "farming" && String(item.ratio ?? "").length > 0,
     }))
 );
 
 const syncCurrentList = () => {
-    const tabValue = floatTabLabels.value[props.activeTab]?.value;
-    currentList.value = props.farmRecordData?.[tabValue] || [];
+    if (!isAgriRecordTab.value) {
+        currentList.value = [];
+        return;
+    }
+    currentList.value = props.farmRecordData?.[activeSubTabValue.value] || [];
 };
 
-const changeTab = (item, index) => {
+const changePrimaryTab = (index) => {
     emit("update:activeTab", index);
-    currentList.value = props.farmRecordData?.[item.value] || [];
+    syncCurrentList();
 };
 
-const activeBgStyle = computed(() => ({
+const changeSubTab = (index) => {
+    emit("update:activeSubTab", index);
+    syncCurrentList();
+};
+
+const primaryActiveBgStyle = computed(() => ({
     transform: `translateX(${props.activeTab * 100}%)`,
 }));
 
@@ -100,6 +202,10 @@ watch(
 watch(() => props.activeTab, () => {
     syncCurrentList();
 });
+
+watch(() => props.activeSubTab, () => {
+    syncCurrentList();
+});
 </script>
 
 <style lang="scss" scoped>
@@ -149,7 +255,7 @@ watch(() => props.activeTab, () => {
         padding: 3px;
         background: #E9E9E9;
         display: grid;
-        grid-template-columns: repeat(3, minmax(0, 1fr));
+        grid-template-columns: repeat(2, minmax(0, 1fr));
         align-items: center;
         overflow: hidden;
 
@@ -157,7 +263,7 @@ watch(() => props.activeTab, () => {
             position: absolute;
             top: 3px;
             left: 3px;
-            width: calc((100% - 6px) / 3);
+            width: calc((100% - 6px) / 2);
             height: 26px;
             border-radius: 4px;
             background: #fff;
@@ -181,6 +287,33 @@ watch(() => props.activeTab, () => {
         }
     }
 
+    .float-sub-tabs {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        margin-bottom: 10px;
+
+        .sub-tab-item {
+            height: 26px;
+            line-height: 24px;
+            padding: 0 10px;
+            font-size: 12px;
+            color: #767676;
+            background: #e9e9e9;
+            border-radius: 4px;
+            border: 1px solid transparent;
+            box-sizing: border-box;
+            cursor: pointer;
+            transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
+
+            &.sub-tab-item-active {
+                color: #2199f8;
+                background: #fff;
+                border-color: #2199f8;
+            }
+        }
+    }
+
     .tab-content-group {
         padding-top: 12px;
 
@@ -221,6 +354,41 @@ watch(() => props.activeTab, () => {
                 color: #2199f8;
             }
         }
+
+        .remote-sensing-chart {
+            &__header {
+                display: flex;
+                align-items: center;
+                justify-content: space-between;
+                gap: 12px;
+            }
+
+            &__legend {
+                display: flex;
+                align-items: center;
+                justify-content: flex-end;
+                flex-wrap: wrap;
+                gap: 12px;
+            }
+
+            &__legend-item {
+                display: flex;
+                align-items: center;
+                gap: 6px;
+            }
+
+            &__legend-line {
+                width: 14px;
+                height: 4px;
+                border-radius: 2px;
+            }
+
+            &__legend-text {
+                font-size: 12px;
+                color: #666666;
+                line-height: 18px;
+            }
+        }
     }
 }
 </style>

+ 210 - 0
src/views/old_mini/agri_file/components/remoteSensingChart.vue

@@ -0,0 +1,210 @@
+<template>
+    <div ref="chartRef" class="remote-sensing-line-chart"></div>
+</template>
+
+<script setup>
+import * as echarts from "echarts";
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
+import { useI18n } from "@/i18n";
+
+const props = defineProps({
+    chartData: {
+        type: Object,
+        default: null,
+    },
+});
+
+const { t } = useI18n();
+
+const chartRef = ref(null);
+const chartInstance = shallowRef(null);
+
+const normalizedData = computed(() => {
+    const data = props.chartData;
+    if (!data?.zoneSeries?.length || !data?.standardSeries?.length) {
+        return null;
+    }
+
+    const zoneSeries = data.zoneSeries.map((item) => Number(item));
+    const standardSeries = data.standardSeries.map((item) => Number(item));
+    const length = Math.min(zoneSeries.length, standardSeries.length);
+    const timeLabels =
+        data.timeLabels?.length >= length
+            ? data.timeLabels.slice(0, length)
+            : Array.from({ length }, () => t("agriFile.timeAxisLabel"));
+
+    let highlightIndex = data.highlightIndex;
+    if (highlightIndex == null || highlightIndex < 0 || highlightIndex >= length) {
+        highlightIndex = zoneSeries.reduce(
+            (maxIndex, value, index, list) => (value > list[maxIndex] ? index : maxIndex),
+            0
+        );
+    }
+
+    return {
+        timeLabels,
+        zoneSeries: zoneSeries.slice(0, length),
+        standardSeries: standardSeries.slice(0, length),
+        highlightIndex,
+    };
+});
+
+const formatAxisValue = (value) => {
+    const num = Number(value);
+    if (Number.isNaN(num)) return value;
+    if (num === 0) return "0";
+    if (num === 1.2) return "1.2";
+    return Number.isInteger(num) ? String(num) : num.toFixed(1);
+};
+
+const formatHighlightValue = (value) => {
+    const num = Number(value);
+    if (Number.isNaN(num)) return t("agriFile.remoteSensingValuePlaceholder");
+    return Number.isInteger(num) ? String(num) : num.toFixed(2).replace(/\.?0+$/, "");
+};
+
+const buildOption = (data) => {
+    const highlightValue = data.zoneSeries[data.highlightIndex];
+
+    return {
+        animation: false,
+        grid: {
+            left: 4,
+            right: 8,
+            top: 14,
+            bottom: 5,
+            containLabel: true,
+        },
+        xAxis: {
+            type: "category",
+            boundaryGap: false,
+            data: data.timeLabels,
+            axisLine: { show: false },
+            axisTick: { show: false },
+            axisLabel: {
+                color: "#C1C1C1",
+                fontSize: 11,
+                rotate: 25,
+                margin: 10,
+            },
+        },
+        yAxis: {
+            type: "value",
+            min: 0,
+            max: 1.2,
+            interval: 0.2,
+            axisLine: { show: false },
+            axisTick: { show: false },
+            axisLabel: {
+                color: "#C1C1C1",
+                fontSize: 11,
+                formatter: formatAxisValue,
+            },
+            splitLine: {
+                lineStyle: {
+                    color: "#EEEEEE",
+                },
+            },
+        },
+        series: [
+            {
+                name: "zone",
+                type: "line",
+                smooth: true,
+                symbol: "none",
+                lineStyle: {
+                    width: 2,
+                    color: "#2199F8",
+                },
+                areaStyle: {
+                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                        { offset: 0, color: "rgba(33, 153, 248, 0.7)" },
+                        { offset: 1, color: "rgba(33, 153, 248, 0)" },
+                    ]),
+                },
+                data: data.zoneSeries,
+                markPoint: {
+                    symbol: "circle",
+                    symbolSize: 7,
+                    itemStyle: {
+                        color: "#ffffff",
+                        borderColor: "#2199F8",
+                        borderWidth: 2,
+                    },
+                    label: {
+                        show: true,
+                        position: "top",
+                        distance: 6,
+                        color: "#2199F8",
+                        fontSize: 12,
+                        formatter: () => `[${formatHighlightValue(highlightValue)}]`,
+                    },
+                    data: [
+                        {
+                            coord: [data.highlightIndex, highlightValue],
+                        },
+                    ],
+                },
+                z: 3,
+            },
+            {
+                name: "standard",
+                type: "line",
+                smooth: true,
+                symbol: "none",
+                lineStyle: {
+                    width: 2,
+                    color: "#9FD2FA",
+                },
+                data: data.standardSeries,
+                z: 2,
+            },
+        ],
+    };
+};
+
+const renderChart = () => {
+    if (!chartRef.value || !normalizedData.value) return;
+
+    if (!chartInstance.value) {
+        chartInstance.value = echarts.init(chartRef.value);
+    }
+
+    chartInstance.value.setOption(buildOption(normalizedData.value), true);
+};
+
+const resizeChart = () => {
+    chartInstance.value?.resize();
+};
+
+onMounted(() => {
+    nextTick(() => {
+        renderChart();
+    });
+    window.addEventListener("resize", resizeChart);
+});
+
+onBeforeUnmount(() => {
+    window.removeEventListener("resize", resizeChart);
+    chartInstance.value?.dispose();
+    chartInstance.value = null;
+});
+
+watch(
+    normalizedData,
+    () => {
+        nextTick(() => {
+            renderChart();
+        });
+    },
+    { deep: true }
+);
+</script>
+
+<style lang="scss" scoped>
+.remote-sensing-line-chart {
+    width: 100%;
+    height: 180px;
+    margin-top: 10px;
+}
+</style>

+ 198 - 40
src/views/old_mini/agri_file/index.vue

@@ -23,15 +23,50 @@
         </div>
 
         <div class="file-content" v-show="activeGardenTab === 'current'">
+            <!-- 地图图例:分类标识 + 长势等级 -->
             <div class="map-legend">
-                <div v-for="item in mapLegendItems" :key="item.key" class="map-legend__item">
-                    <span class="map-legend__dot" :class="item.dotClass"></span>
+                <div
+                    v-for="item in mapLegendItems"
+                    :key="item.key"
+                    class="map-legend__item"
+                >
+                    <span class="map-legend__pill" :class="item.pillClass"></span>
                     <span class="map-legend__text">{{ item.label }}</span>
                 </div>
             </div>
+            <div class="map-legend-box">
+                <div class="map-legend-box__labels">
+                    <span
+                        v-for="(label, index) in growthScaleLabels"
+                        :key="index"
+                    >{{ label }}</span>
+                </div>
+                <div class="map-legend-box__bar"></div>
+            </div>
+            <div class="map-tool-bar">
+                <div
+                    v-for="(item, index) in mapToolItems"
+                    :key="item.key"
+                    class="map-tool-bar__item"
+                    :class="{ 'map-tool-bar__item--active': activeMapTool === index }"
+                    @click="activeMapTool = index"
+                >
+                    <img
+                        class="map-tool-bar__icon"
+                        :src="require(`@/assets/img/map/tool-${index + 1}.png`)"
+                        :alt="item.label"
+                    />
+                    <span class="map-tool-bar__label">{{ item.label }}</span>
+                </div>
+            </div>
             <div class="map-container" ref="mapContainer"></div>
-            <file-float v-model:active-tab="activeRecordTab" :farm-record-data="farmRecordData"
-                :loading="farmRecordLoading" />
+            <file-float
+                v-model:active-tab="activeRecordTab"
+                v-model:active-sub-tab="activeRecordSubTab"
+                :farm-record-data="farmRecordData"
+                :loading="farmRecordLoading"
+                :remote-sensing-data="remoteSensingChartData"
+            />
         </div>
     </div>
 </template>
@@ -59,23 +94,64 @@ const gardenListRef = ref(null);
 const activeGardenTab = ref("current");
 const activeType = ref("荔枝");
 
-const mapLegendItems = computed(() => [
-    { key: "zone", label: t("agriFile.legendZone"), dotClass: "map-legend__dot--zone" },
-    { key: "growth", label: t("agriFile.legendGrowth"), dotClass: "map-legend__dot--growth" },
-    { key: "pest", label: t("agriFile.legendPest"), dotClass: "map-legend__dot--pest" },
-]);
+const MAP_LEGEND_CONFIG = [
+    { key: "zone", labelKey: "agriFile.legendZone", type: "zone" },
+    { key: "growth", labelKey: "agriFile.legendGrowth", type: "growth" },
+    { key: "pest", labelKey: "agriFile.legendPest", type: "pest" },
+];
+
+const GROWTH_SCALE_LABEL_KEYS = [
+    "agriFile.scaleNormal",
+    "agriFile.scaleGood",
+    "agriFile.scaleExcellent",
+];
+
+const mapLegendItems = computed(() =>
+    MAP_LEGEND_CONFIG.map(({ key, labelKey, type }) => ({
+        key,
+        label: t(labelKey),
+        pillClass: `map-legend__pill--${type}`,
+    }))
+);
+
+const growthScaleLabels = computed(() => GROWTH_SCALE_LABEL_KEYS.map((key) => t(key)));
+
+const MAP_TOOL_ITEMS = [
+    { key: "habitat", label: "生境" },
+    { key: "light", label: "光照" },
+    { key: "water", label: "水源" },
+    { key: "fengshui", label: "风水" },
+    { key: "soil", label: "土壤" },
+];
+
+const mapToolItems = MAP_TOOL_ITEMS;
+const activeMapTool = ref(1);
 
 const fileMap = new FileMap();
 
 const farmRecordData = ref({});
 const farmRecordLoading = ref(false);
+
+const REMOTE_SENSING_MOCK_POINTS = [
+    { time: "时间", zone: 0.52, standard: 0.38 },
+    { time: "时间", zone: 0.88, standard: 0.62 },
+    { time: "时间", zone: 1.12, standard: 0.84, highlight: true },
+    { time: "时间", zone: 0.76, standard: 0.58 },
+    { time: "时间", zone: 0.94, standard: 0.72 },
+    { time: "时间", zone: 0.68, standard: 0.5 },
+    { time: "时间", zone: 0.82, standard: 0.64 },
+];
+
+const remoteSensingChartData = ref(REMOTE_SENSING_MOCK_POINTS);
 const mapPolygonWkt = ref([]);
 const activeRecordTab = ref(0);
+const activeRecordSubTab = ref(0);
 
-const RECORD_TAB_KEYS = ["phenology", "abnormal", "farming"];
+const RECORD_SUB_TAB_KEYS = ["phenology", "abnormal", "farming"];
 
 const syncFarmRecordMap = () => {
-    const tabKey = RECORD_TAB_KEYS[activeRecordTab.value] || RECORD_TAB_KEYS[0];
+    if (activeRecordTab.value !== 0) return;
+    const tabKey = RECORD_SUB_TAB_KEYS[activeRecordSubTab.value] || RECORD_SUB_TAB_KEYS[0];
     const records = farmRecordData.value?.[tabKey] || [];
     // fileMap.setFarmRecordOverlay?.(mapPolygonWkt.value, records);
 };
@@ -95,6 +171,7 @@ const getFarmRecord = async () => {
 };
 
 watch(activeRecordTab, syncFarmRecordMap);
+watch(activeRecordSubTab, syncFarmRecordMap);
 watch(locale, syncFarmRecordMap);
 
 const weatherExpanded = (isExpandedValue) => {
@@ -189,48 +266,129 @@ onActivated(() => {
         height: 100%;
         box-sizing: border-box;
 
-        .map-legend {
+        $map-legend-panel-bg: rgba(0, 0, 0, 0.46);
+        $map-legend-text-color: #ffffff;
+        $map-legend-text-size: 12px;
+        $map-legend-scale-gradient: linear-gradient(
+            90deg,
+            #007aff 0%,
+            #00d4aa 25%,
+            #ffe600 50%,
+            #ff9500 75%,
+            #ff3b30 100%
+        );
+
+        @mixin map-legend-panel-base {
             position: absolute;
-            left: 50%;
-            transform: translateX(-50%);
             z-index: 15;
-            top: 110px;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            gap: 6px;
-            padding: 3px 5px;
-            background: rgba(255, 255, 255, 0.69);
-            border-radius: 25px;
+            background: $map-legend-panel-bg;
+            backdrop-filter: blur(4px);
+            box-sizing: border-box;
         }
 
-        .map-legend__item {
+        .map-legend {
+            @include map-legend-panel-base;
+            top: 110px;
+            right: 12px;
             display: flex;
             align-items: center;
-            gap: 5px;
-        }
+            justify-content: space-around;
+            gap: 10px;
+            padding: 8px 10px;
+            border-radius: 4px;
+
+            &__item {
+                display: flex;
+                align-items: center;
+                gap: 5px;
+            }
 
-        .map-legend__dot {
-            width: 12px;
-            height: 12px;
-            border-radius: 50%;
-        }
+            &__pill {
+                width: 16px;
+                height: 5px;
+                border-radius: 10px;
 
-        .map-legend__dot--zone {
-            background: #1C9E80;
-        }
+                &--zone {
+                    background: #1c9e80;
+                }
+
+                &--growth {
+                    background: #ff953d;
+                }
 
-        .map-legend__dot--growth {
-            background: #FF953D;
+                &--pest {
+                    background: #e03131;
+                }
+            }
+
+            &__text {
+                font-size: $map-legend-text-size;
+                color: $map-legend-text-color;
+            }
         }
 
-        .map-legend__dot--pest {
-            background: #E03131;
+        .map-legend-box {
+            @include map-legend-panel-base;
+            top: 150px;
+            right: 10px;
+            width: 222px;
+            padding: 5px 10px;
+            border-radius: 5px;
+
+            &__labels {
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+                margin-bottom: 8px;
+                font-size: $map-legend-text-size;
+                color: $map-legend-text-color;
+            }
+
+            &__bar {
+                height: 8px;
+                border-radius: 4px;
+                background: $map-legend-scale-gradient;
+            }
         }
 
-        .map-legend__text {
-            font-size: 12px;
-            white-space: nowrap;
+        .map-tool-bar {
+            position: absolute;
+            left: 12px;
+            top: 50%;
+            transform: translateY(-50%);
+            z-index: 15;
+            display: flex;
+            flex-direction: column;
+            align-items: center;
+            padding: 10px 6px;
+            background: #ffffff;
+            border-radius: 7px;
+            box-shadow: 0px 2.3px 2.3px 0px #0000001A;
+            box-sizing: border-box;
+
+            &__item {
+                display: flex;
+                flex-direction: column;
+                align-items: center;
+                justify-content: center;
+                padding: 10px 5px;
+                color: #C1C1C1;
+
+                &--active {
+                    color: #2199f8;
+                }
+            }
+
+            &__icon {
+                width: 18px;
+                height: 16px;
+                margin-bottom: 3px;
+                // filter: grayscale(0);
+            }
+
+            &__label {
+                font-size: 12px;
+            }
         }
 
         .map-container {