Skip to content

Commit

Permalink
feat: kvmap ported to kv components library (#286)
Browse files Browse the repository at this point in the history
* feat: kvmap ported to kv components library

* fix: utilities removed

* fix: lock file removed

* feat: vue meta package added
  • Loading branch information
roger-in-kiva authored Aug 29, 2023
1 parent cec2949 commit 373e53f
Show file tree
Hide file tree
Showing 5 changed files with 464 additions and 0 deletions.
1 change: 1 addition & 0 deletions @kiva/kv-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"tailwindcss": "^3.0.18",
"vue": "^2.6.14",
"vue-loader": "^15.9.6",
"vue-meta": "^2.4.0",
"vue-router": "^3.5.2",
"vue-template-compiler": "^2.6.12"
},
Expand Down
4 changes: 4 additions & 0 deletions @kiva/kv-components/vue/.storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import addons from '@storybook/addons';
import KvThemeProvider from '../KvThemeProvider.vue';
import { defaultTheme, darkTheme } from '@kiva/kv-tokens/configs/kivaColors.cjs';
import Vue from 'vue';
import Meta from 'vue-meta';
import VueCompositionApi from '@vue/composition-api';
import VueRouter from 'vue-router';

Expand All @@ -11,6 +12,9 @@ Vue.use(VueCompositionApi);

Vue.use(VueRouter);

// initialize vue-meta
Vue.use(Meta);

export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
Expand Down
370 changes: 370 additions & 0 deletions @kiva/kv-components/vue/KvMap.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,370 @@
<template>
<div
class="tw-relative tw-block tw-w-full"
:style="mapDimensions"
>
<div
:id="`kv-map-holder-${mapId}`"
:ref="refString"
class="tw-w-full tw-h-full tw-bg-black"
:style="{ position: 'absolute' }"
></div>
</div>
</template>

<script>
export default {
name: 'KvMap',
metaInfo() {
return {
script: [].concat(!this.hasWebGL ? [
// leaflet - uses raster tiles for additional browser coverage
{
vmid: `leafletjs${this.mapId}`,
src: 'https://unpkg.com/[email protected]/dist/leaflet.js',
async: true,
defer: true,
},
] : []).concat(this.hasWebGL ? [
// maplibregl - uses vector tiles and webgl for rendering
{
vmid: `maplibregljs${this.mapId}`,
src: 'https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js',
async: true,
defer: true,
},
] : []),
link: [
].concat(!this.hasWebGL ? [
// leaflet - uses raster tiles for additional browser coverage
{
vmid: `leafletcss${this.mapId}`,
rel: 'stylesheet',
href: 'https://unpkg.com/[email protected]/dist/leaflet.css',
},
] : []).concat(this.hasWebGL ? [
// maplibregl - uses vector tiles and webgl for rendering
{
vmid: `maplibreglcss${this.mapId}`,
rel: 'stylesheet',
href: 'https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css',
},
] : []),
};
},
props: {
/**
* Aspect Ration for computed map dimensions
* We'll divide the container width by this to determine the height
*/
aspectRatio: {
type: Number,
default: 1,
},
/**
* Control how quickly the autoZoom occurs
*/
autoZoomDelay: {
type: Number,
default: 1500,
},
/**
* Set the height to override aspect ratio driven and/or default dimensions
*/
height: {
type: Number,
default: null,
},
/**
* Setting this initialZoom will zoom the map from initialZoom to zoom when the map enters the viewport
*/
initialZoom: {
type: Number,
default: null,
},
/**
* Set the center point latitude
*/
lat: {
type: Number,
default: null,
},
/**
* Set the center point longitude
*/
long: {
type: Number,
default: null,
},
/**
* Set this if there are more than one map on the page
*/
mapId: {
type: Number,
default: 0,
},
/**
* Force use of Leaflet
*/
useLeaflet: {
type: Boolean,
default: false,
},
/**
* Set the width to override aspect ratio driven and/or default dimensions
*/
width: {
type: Number,
default: null,
},
/**
* Default zoom level
*/
zoomLevel: {
type: Number,
default: 4,
},
},
data() {
return {
hasWebGL: false,
leafletReady: false,
mapInstance: null,
mapLibreReady: false,
mapLoaded: false,
zoomActive: false,
};
},
computed: {
mapDimensions() {
// Use container to derive height based on aspect ration + width
const container = this.$el?.getBoundingClientRect();
const height = container ? `${container.width / this.aspectRatio}px` : '300px';
const width = container ? `${container.width}px` : '100%';
// Override values if deliberate height or width are provided
return {
height: this.height ? `${this.height}px` : height,
width: this.width ? `${this.width}px` : width,
paddingBottom: this.height ? `${this.height}px` : `${100 / this.aspectRatio}%`,
};
},
refString() {
return `mapholder${this.mapId}`;
},
},
watch: {
lat(next, prev) {
if (prev === null && this.long && !this.mapLibreReady && !this.leafletReady) {
this.initializeMap();
}
},
long(next, prev) {
if (prev === null && this.lat && !this.mapLibreReady && !this.leafletReady) {
this.initializeMap();
}
},
},
mounted() {
if (!this.mapLibreReady && !this.leafletReady) {
this.initializeMap();
}
},
beforeDestroy() {
if (this.mapInstance) {
if (!this.hasWebGL && !this.leafletReady) {
// turn off the leaflet instance
this.mapInstance.off();
}
// remove either leaflet or maplibregl
this.mapInstance.remove();
}
this.destroyWrapperObserver();
},
methods: {
activateZoom(zoomOut = false) {
const { mapInstance, hasWebGL, mapLibreReady } = this;
const currentZoomLevel = mapInstance.getZoom();
// exit if already zoomed in (getZoom() works for both leaflet + maplibregl)
if ((!zoomOut && currentZoomLevel === this.zoomLevel)
|| (zoomOut && currentZoomLevel === this.initialZoom)) return false;
this.zoomActive = true;
// establish delayed zoom duration
const timedZoom = window.setTimeout(() => {
if (hasWebGL && mapLibreReady) {
// maplibregl specific zoom method
mapInstance.zoomTo(
zoomOut ? this.initialZoom : this.zoomLevel,
{ duration: 1200 },
);
} else {
// leaflet specific zoom method
mapInstance.setZoom(zoomOut ? this.initialZoom : this.zoomLevel);
}
clearTimeout(timedZoom);
this.zoomActive = false;
}, this.autoZoomDelay);
},
createWrapperObserver() {
// Watch for the wrapper element moving in and out of the viewport
this.wrapperObserver = this.createIntersectionObserver({
targets: [this.$refs?.[this.refString]],
callback: (entries) => {
entries.forEach((entry) => {
if (entry.target === this.$refs?.[this.refString] && !this.zoomActive) {
if (entry.intersectionRatio > 0) {
this.activateZoom();
}
}
});
},
});
},
destroyWrapperObserver() {
if (this.wrapperObserver) {
this.wrapperObserver.disconnect();
}
},
checkWebGL() {
// exit and use leaflet if specified or document isn't present
if (this.useLeaflet || typeof document === 'undefined') return false;
// via. https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/By_example/Detect_WebGL
// Create canvas element. The canvas is not added to the document itself,
// so it is never displayed in the browser window.
const canvas = document.createElement('canvas');
// Get WebGLRenderingContext from canvas element.
const gl = canvas.getContext('webgl')
|| canvas.getContext('experimental-webgl');
// Report the result.
if (gl && gl instanceof WebGLRenderingContext) {
this.hasWebGL = true;
return true;
}
return false;
},
initializeMap() {
/**
* This initial checkWebGL() call kicks off the vue-meta asset inclusion
* We then start polling for the readiness of our selected map library and initialize it once ready
*/
if (this.checkWebGL()) {
this.testDelayedGlobalLibrary('maplibregl').then((response) => {
if (response.loaded && !this.mapLoaded && !this.useLeaflet && this.lat && this.long) {
this.initializeMapLibre();
this.mapLibreReady = true;
}
});
} else {
this.testDelayedGlobalLibrary('L').then((leafletTest) => {
if (leafletTest.loaded && !this.mapLoaded && this.lat && this.long) {
this.initializeLeaflet();
this.leafletReady = true;
}
});
}
},
initializeLeaflet() {
/* eslint-disable no-undef, max-len */
// Initialize primary mapInstance
this.mapInstance = L.map(`kv-map-holder-${this.mapId}`, {
center: [this.lat, this.long],
zoom: this.initialZoom || this.zoomLevel,
// todo make props for the following options
dragging: false,
zoomControl: false,
animate: true,
scrollWheelZoom: false,
doubleClickZoom: false,
attributionControl: false,
});
/* eslint-disable quotes */
// Add our tileset to the mapInstance
L.tileLayer('https://api.maptiler.com/maps/bright/{z}/{x}/{y}.png?key=n1Mz5ziX3k6JfdjFe7mx', {
tileSize: 512,
zoomOffset: -1,
minZoom: 1,
crossOrigin: true,
}).addTo(this.mapInstance);
/* eslint-enable quotes */
/* eslint-enable no-undef, max-len */
// signify map has loaded
this.mapLoaded = true;
// only activate autoZoom if we have an initialZoom set
if (this.initialZoom !== null) {
this.createWrapperObserver();
}
},
initializeMapLibre() {
// Initialize primary mapInstance
// eslint-disable-next-line no-undef
this.mapInstance = new maplibregl.Map({
container: `kv-map-holder-${this.mapId}`,
style: 'https://api.maptiler.com/maps/bright/style.json?key=n1Mz5ziX3k6JfdjFe7mx',
center: [this.long, this.lat],
zoom: this.initialZoom || this.zoomLevel,
attributionControl: false,
dragPan: false,
scrollZoom: false,
doubleClickZoom: false,
dragRotate: false,
});
// signify map has loaded
this.mapLoaded = true;
// only activate autoZoom if we have an initialZoom set
if (this.initialZoom !== null) {
this.createWrapperObserver();
}
},
checkIntersectionObserverSupport() {
if (typeof window === 'undefined'
|| !('IntersectionObserver' in window)
|| !('IntersectionObserverEntry' in window)
|| !('intersectionRatio' in window.IntersectionObserverEntry.prototype)) {
return false;
}
return true;
},
createIntersectionObserver({ callback, options, targets } = {}) {
if (this.checkIntersectionObserverSupport()) {
const observer = new IntersectionObserver(callback, options);
targets.forEach((target) => observer.observe(target));
return observer;
}
},
testDelayedGlobalLibrary(library, timeout = 3000) {
// return a promise
return new Promise((resolve, reject) => {
if (typeof window === 'undefined') {
reject(new Error('window object not available'));
}
// establish timeout to limit time until promise resolution
let readyStateTimeout;
// establish interval to check for library presence
const readyStateInterval = window.setInterval(() => {
// determine if library is present on window
if (typeof window[library] !== 'undefined') {
// cleanup timers
clearInterval(readyStateInterval);
clearTimeout(readyStateTimeout);
// resolve the promise
resolve({ loaded: true });
}
}, 100);
// activate timeout
readyStateTimeout = window.setTimeout(() => {
// clean up interval and timeout
clearInterval(readyStateInterval);
clearTimeout(readyStateTimeout);
// resolve the promise
resolve({ loaded: false });
}, timeout);
});
},
},
};
</script>
Loading

0 comments on commit 373e53f

Please sign in to comment.