Загрузка данных


<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>