Загрузка данных
<template>
<div>
<div class="columns mx-1 mt-1">
<div class="column is-6-tablet is-8-widescreen is-8-fullhd main-map">
<l-map
ref="map"
:max-bounds="$options.MapSettings.maxBounds"
:min-zoom="$options.MapSettings.minZoom"
:max-zoom="$options.MapSettings.maxZoom"
:zoom="$options.MapSettings.zoom"
:center="$options.MapSettings.center"
:bounds="bounds"
@ready="onMapReady"
>
<l-tile-layer :url="$options.MapSettings.url" />
<v-marker-cluster ref="cluster" :options="$options.ClusterOptions">
<l-marker
v-for="object in list"
:key="`object_${object.b_id}`"
:lat-lng="[object.a_lat, object.a_lon]"
>
<l-icon :class-name="`object-icon ${object.o_free ? 'border-green' : object.o_sc ? 'border-red' : 'border-orange'}`">
<b-icon icon="building" size="is-small" />
</l-icon>
<l-popup>
{{ object.a_name }}
<br />
<span v-for="providerId in object.competitors" :key="providerId" class="tag is-primary is-light mr-1">
{{ providers[providerId].p_name }}
</span>
</l-popup>
</l-marker>
</v-marker-cluster>
</l-map>
</div>
<div class="column">
<div class="subtitle is-5 has-background-info-light p-2 mb-2">
Список объектов на карте ({{ visibleAddresses.length }} шт.):
</div>
<b-table
:data="visibleAddresses"
bordered
narrowed
hoverable
paginated
:per-page="perPage"
:current-page="currentPage"
pagination-size="is-small"
sticky-header
height="calc(100vh - 280px)"
class="smallTable is-size-7 fullHeight"
:row-class="(row) => !row.a_house_number && 'has-background-danger-light'"
detailed
detail-key="b_id"
:show-detail-icon="false"
:opened-detailed="openedDetails"
@click="(row) => openedDetails = openedDetails[0] === row.b_id ? [] : [row.b_id]"
>
<b-table-column v-slot="props" field="id" label="№" width="20">
<div>{{ props.index + 1 }}</div>
<b-icon
v-if="!props.row.a_house_number"
icon="exclamation-circle"
type="is-danger"
title="Неверно выбрана геометка у адреса. Сверьте название по оригинальному адресу из АСТУП файла и найдите актуальный адрес из OpenStreetMap"
style="width: 12px"
/>
</b-table-column>
<b-table-column v-slot="props" field="a_name" label="Адрес" width="150">
<div class="has-text-weight-semibold" title="Адрес после геокодирования OpenStreetMap" style="cursor: default">
<span v-if="props.row.a_city">{{ props.row.a_city }},</span> {{ props.row.a_name }}
</div>
<div
:class="{ 'tag is-success': !props.row.a_house_number, 'has-text-grey': props.row.a_house_number }"
title="Оригинальный адрес из файла АСТУП"
style="cursor: default"
>
{{ `${props.row.b_eva}, ${props.row.b_number}` }}
</div>
</b-table-column>
<b-table-column v-slot="props" field="b_entrance_count" label="Подъездов" width="20">
{{ props.row.b_entrance_count }}
</b-table-column>
<b-table-column v-slot="props" field="b_floor" label="Этажей" width="20">
{{ props.row.b_floor }}
</b-table-column>
<b-table-column v-slot="props" field="b_flat" label="Квартир" width="20">
{{ props.row.b_flat }}
</b-table-column>
<b-table-column v-slot="props" field="competitors" label="Конкуренты" width="50" centered>
<span v-for="providerId in props.row.competitors" :key="providerId" class="tag is-primary is-light mr-1">
{{ providers[providerId].p_name }}
</span>
</b-table-column>
<template slot="detail" slot-scope="props">
<div class="columns">
<div class="column">
<div class="box" :class="{ 'has-background-grey-light': props.row.b_deleted }">
<b-field>
<div class="title is-5" style="width: 100%">{{ `${props.row.b_eva}, ${props.row.b_number}` }}</div>
<div style="width: 30px">
<button
v-if="!props.row.b_deleted && isOperator"
class="button is-success is-outlined is-small is-fullwidth"
@click.stop="buildingModal_visible = true"
>
<b-icon icon="pen" aria-hidden="true" />
</button>
</div>
</b-field>
<b-tag v-if="props.row.b_deleted" type="is-danger">
Удален {{ new Date(props.row.b_deleted).toLocaleDateString() }}
</b-tag>
<b-field class="label_30" horizontal label="Адрес OpenStreetMap">
<div>
<span v-if="props.row.a_city">{{ props.row.a_city }},</span> {{ props.row.a_name }}
</div>
</b-field>
<b-field class="label_30" horizontal label="Примечание">{{ props.row.b_note }}</b-field>
</div>
</div>
<div class="column">
<div class="box">
<div class="media">
<div class="media-content">
<div class="subtitle is-5">Список конкурентов</div>
</div>
<div class="media-right">
<button
v-if="!props.row.b_deleted && isOperator"
class="button is-info is-small is-fullwidth"
@click="openOrganizationModal(null)"
>
Добавить конкурента
</button>
</div>
</div>
<b-table v-if="props.row.competitors" :data="props.row.competitors" :bordered="false" striped narrowed hoverable class="is-size-7">
<template>
<b-table-column v-slot="{ row }" field="p_id" label="№" width="10">
{{ providers[row].p_id }}
</b-table-column>
<b-table-column v-slot="{ row }" field="p_name" label="Наименование" width="50">
{{ providers[row].p_name }}
</b-table-column>
<b-table-column v-slot="{ row }" field="edit" label="" width="10">
<button
class="button is-primary is-small is-outlined"
style="height: 24px"
disabled
title="Редактировать конкурента"
@click="openOrganizationModal(row)"
>
<b-icon icon="pen" />
</button>
</b-table-column>
</template>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>Данные не получены</p>
</div>
</section>
</template>
</b-table>
<div v-else class="subtitle is-6 has-text-weight-bold mt-2">Адреса не добавлены</div>
</div>
</div>
</div>
</template>
<template #empty>
<div class="content has-text-grey has-text-centered">
<b-icon class="my-icon height-100 is-size-1" icon="blind" size="is-large" />
<p class="mb-3">Ничего не найдено</p>
</div>
</template>
</b-table>
</div>
</div>
<b-modal v-model="buildingModal_visible" :active.sync="buildingModal_visible" has-modal-card width="1000px">
<Building-modal
:edit_building="recordSelected"
:providers="providers"
@close-modal="buildingModal_visible = false"
@update-info="onUpdateInfo"
/>
</b-modal>
</div>
</template>
<script>
import debounce from 'lodash/debounce';
import L from 'leaflet';
import { Icon } from 'leaflet';
import { LMap, LTileLayer, LMarker, LIcon, LPopup } from 'vue2-leaflet';
import Vue2LeafletMarkerCluster from 'vue2-leaflet-markercluster';
import 'leaflet/dist/leaflet.css';
// Для правильного отображения иконок Leaflet
delete Icon.Default.prototype._getIconUrl;
Icon.Default.mergeOptions({
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
iconUrl: require('leaflet/dist/images/marker-icon.png'),
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
});
import { wrapWithLoading } from '../../../mixins/wrappers.mixin';
import { showDangerToast } from '../../../mixins/toast.mixin';
import BuildingModal from '../modals/buildingModal.vue';
export default {
components: {
LMap,
LTileLayer,
LMarker,
LIcon,
LPopup,
'v-marker-cluster': Vue2LeafletMarkerCluster,
BuildingModal,
},
mixins: [wrapWithLoading, showDangerToast],
props: {
list: { type: Array, required: true },
providers: { type: Object, required: true },
isOperator: { type: Boolean, default: false }
},
// Выносим статические настройки карты за пределы реактивного data()
MapSettings: {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
center: [53.9, 27.56],
zoom: 12,
minZoom: 5,
maxZoom: 18,
maxBounds: L.latLngBounds([
[51.190785980509354, 23.076354200895736],
[56.245591731745186, 32.878022287814566],
]),
},
ClusterOptions: {
spiderfyDistanceMultiplier: 1,
chunkedLoading: true, // ВАЖНО: включает асинхронную подгрузку и устраняет зависания браузера
maxClusterRadius: 70,
disableClusteringAtZoom: 16,
},
data() {
return {
visibleAddresses: [],
openedDetails: [],
buildingModal_visible: false,
// Переменные для модалки организации (добавлены так как они использовались в template)
organizationModal_visible: false,
selectedOrganization: null,
perPage: 50,
currentPage: 1,
bounds: L.latLngBounds([
[53.88, 27.4],
[53.93, 27.7],
]),
};
},
computed: {
recordSelected() {
if (!this.openedDetails.length || !this.list.length) return null;
return this.list.find((e) => e.b_id === this.openedDetails[0]);
},
},
created() {
// Сохраняем объект карты в нереактивной переменной, чтобы избежать колоссальных утечек памяти Vue
this._mapInstance = null;
},
beforeDestroy() {
if (this._mapInstance) {
this._mapInstance.off('moveend', this.updateVisibleAddresses);
}
},
methods: {
onMapReady(map) {
this._mapInstance = map;
this._mapInstance.on('moveend', this.updateVisibleAddresses);
this.updateVisibleAddresses();
},
// Оптимизированный метод: вместо создания L.latLng в цикле, работаем с чистой математикой
updateVisibleAddresses: debounce(function () {
if (!this._mapInstance) return;
const mapBounds = this._mapInstance.getBounds();
const south = mapBounds.getSouth();
const north = mapBounds.getNorth();
const west = mapBounds.getWest();
const east = mapBounds.getEast();
this.visibleAddresses = this.list.filter((item) => {
const lat = parseFloat(item.a_lat);
const lon = parseFloat(item.a_lon);
return lat >= south && lat <= north && lon >= west && lon <= east;
});
}, 500),
onUpdateInfo() {
this.getList();
if (this.$refs.shortloglist) {
this.$refs.shortloglist.getShortLog();
}
},
openOrganizationModal(org) {
this.selectedOrganization = org;
this.organizationModal_visible = true;
}
},
};
</script>
<style scoped lang="scss">
@import '~leaflet/dist/leaflet.css';
@import '~leaflet.markercluster/dist/MarkerCluster.css';
@import '~leaflet.markercluster/dist/MarkerCluster.Default.css';
.app-main-page .main-map::v-deep {
.vue2leaflet-map {
border-radius: 10px;
box-shadow: 3px 3px 5px #0000005c, 0 0 3px #0000008c;
height: calc(100vh - 180px) !important;
z-index: 1 !important;
.leaflet-marker-icon.object-icon {
background-color: white;
border-style: solid;
display: flex;
align-items: center;
justify-content: center;
height: 28px !important;
width: 28px !important;
border-radius: 24px;
border-width: 2px;
padding: 3px;
&.border-red { border-color: red; &:after { background: red; } }
&.border-green { border-color: #23e635; &:after { background: #23e635; } }
&.border-blue { border-color: #2160e6; &:after { background: #2160e6; } }
&.border-yellow { border-color: yellow; &:after { background: yellow; } }
&.border-violet { border-color: violet; &:after { background: violet; } }
&.border-orange { border-color: orange; &:after { background: orange; } }
&.border-olive { border-color: olive; &:after { background: olive; } }
&.border-turquoise { border-color: turquoise; &:after { background: turquoise; } }
&.border-orangeRed { border-color: OrangeRed; &:after { background: OrangeRed; } }
}
.leaflet-marker-icon.addressPopup {
background-color: white;
border: 2px solid black;
height: 10px;
border-radius: 50%;
}
.leaflet-control-attribution.leaflet-control a[href="https://leafletjs.com"] {
display: none;
}
}
}
.app-map-page::v-deep {
.smallTable {
.table-wrapper {
overflow-x: hidden;
}
::-webkit-scrollbar { width: 7px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { border-radius: 3px; background: #888; }
::-webkit-scrollbar-thumb:hover { background: #555; }
}
.smallScrollCntainer {
.smallScroll {
overflow-x: hidden;
max-height: calc(100vh - 180px - 220px) !important;
overflow-y: scroll;
}
::-webkit-scrollbar { width: 7px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { border-radius: 3px; background: #888; }
::-webkit-scrollbar-thumb:hover { background: #555; }
}
.halfHeight > div.table-wrapper { height: calc(50vh - 140px) !important; }
.fullHeight > div.table-wrapper { height: calc(100vh - 230px) !important; }
@media screen and (max-width: 1000px) {
.columns {
display: block !important;
.column {
width: 100% !important;
.vue2leaflet-map { height: 600px !important; }
.smallTable .table-wrapper { height: 100%; }
}
}
}
.vs__dropdown-menu { width: auto !important; }
.v-select input { height: 20px; }
}
</style>