Many points
vanilla.html
react.html
vue.html
common.css
common.js
input.css
variables.js
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<script src="https://js.api.mappable.world/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
<script src="./variables.js"></script>
<script src="./common.js"></script>
<script>
window.map = null;
main();
async function main() {
await mappable.ready;
const {
MMap,
MMapDefaultSchemeLayer,
MMapFeatureDataSource,
MMapControls,
MMapMarker,
MMapCollection,
MMapLayer,
MMapListener
} = mappable;
const {MMapZoomControl} = await mappable.import('@mappable-world/mappable-default-ui-theme');
const {MMapClusterer, clusterByGrid} = await mappable.import('@mappable-world/mappable-clusterer');
let location = LOCATION,
setLocation = (l) => {
location = l;
reconcile();
updateUrl(count, location, mode, clusterSize);
};
let mode = MODE,
setMode = (m) => {
mode = m;
if (mode === MODE_CLUSTERER) {
if (collection.root === map) {
map.removeChild(collection);
}
map.addChild(clustererI);
} else {
if (clustererI.root === map) {
map.removeChild(clustererI);
}
map.addChild(collection);
}
shownView.innerText = '';
reconcile();
updateUrl(count, location, mode, clusterSize);
};
const reconcile = throttle(() => {
if (mode === MODE_CLUSTERER) {
clustererI.update({features: [...points]});
} else {
let visiblePoints = points;
if (mode === MODE_REMOVE) {
const bounds = map.bounds;
visiblePoints = visiblePoints.filter((p) => isVisible(p, bounds));
shownView.innerText = `Shown: ${visiblePoints.length}`;
}
visiblePoints.forEach((p, i) => {
if (!p.imperative) {
p.imperative = marker(visiblePoints[i]);
}
try {
if (collection.children[i] !== p.imperative) {
if (p.imperative.parent === collection) {
collection.removeChild(p.imperative);
}
collection.addChild(p.imperative, i);
}
} catch (e) {}
});
collection.children.slice(visiblePoints.length).forEach((e) => collection.removeChild(e));
}
}, 100);
let points = [];
const setPoints = (ps) => {
points = ps;
reconcile();
};
let count = DEFAULT_COUNT;
const setCount = async (c) => {
count = c;
const gen = getPointList(count, points);
document.querySelector('.slow').classList.add('show');
updateUrl(count, location, mode, clusterSize);
let currentCount = count;
do {
const {value, done} = gen.next();
if (done) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 0));
if (currentCount !== count) {
break;
}
setPoints(value);
} while (true);
document.querySelector('.slow').classList.remove('show');
};
let clusterSize = DEFAULT_CLUSTER_SIZE;
let gridSizedMethod = clusterByGrid({gridSize: Math.pow(2, clusterSize)});
const setClusterSize = (cs) => {
clusterSize = cs;
clustererI.update({method: clusterByGrid({gridSize: Math.pow(2, clusterSize)})});
updateUrl(count, location, mode, clusterSize);
};
const marker = (p) =>
new MMapMarker(
{
id: p.id + '-' + p.geometry.coordinates.toString(),
source: 'marker-source',
coordinates: p.geometry.coordinates
},
p.markerElement[mode] || p.markerElement[MODE_NONE]
);
const cluster = (coordinates, features) =>
new MMapMarker(
{
id: `${features[0].id}-${features.length}`,
coordinates: coordinates,
source: 'marker-source'
},
circle(features.length)
);
const clustererI = new MMapClusterer({
marker,
cluster,
method: gridSizedMethod,
features: points
});
const collection = new MMapCollection();
map = new MMap(document.getElementById('map'), {location, zoomRange: ZOOM_RANGE}, [
new MMapDefaultSchemeLayer(),
new MMapFeatureDataSource({id: 'marker-source'}),
new MMapLayer({source: 'marker-source', type: 'markers'}),
new MMapControls({position: 'right'}).addChild(new MMapZoomControl({})),
collection
]);
map.addChild(
new MMapListener({
onUpdate: ({location}) => setLocation(location),
onResize: () => setLocation({center: map.center, zoom: map.zoom})
})
);
ui(mode, setMode, count, setCount, clusterSize, setClusterSize);
}
function ui(mode, onSetMode, count, onSetCount, clusterSize, onSetClusterSize) {
const toolbar = document.getElementById('toolbar');
toolbar.classList.add('mode_' + mode);
toolbar.classList.toggle('show-switchers', SHOW_MODE_SWITCHERS);
startDrawFPS(canvasRef);
let location = LOCATION;
let setMode = (m) => {
toolbar.classList.remove('mode_' + mode);
mode = m;
toolbar.classList.add('mode_' + mode);
none.checked = mode === MODE_NONE;
removeHidden.checked = mode === MODE_REMOVE;
clusterer.checked = mode === MODE_CLUSTERER;
onSetMode(mode);
};
let setCount = (c) => {
count = c;
countView.innerText = count;
countRange.value = count;
onSetCount(count);
};
let setClusterSize = (cs) => {
clusterSize = cs;
clusterSizeRange.value = clusterSize;
inlineStyle.innerText = `:root {
--radius: ${(clusterSize / 3) * 20}px
}`;
clusterSizeView.innerText = Math.pow(2, clusterSize);
onSetClusterSize(clusterSize);
};
setMode(MODE);
countRange.step = Math.round((POINTS_MAX - POINTS_MIN) / 5);
countRange.min = POINTS_MIN;
countRange.max = POINTS_MAX;
setCount(DEFAULT_COUNT);
clusterSizeRange.step = 1;
clusterSizeRange.min = CLUSTER_SIZE_MIN;
clusterSizeRange.max = CLUSTER_SIZE_MAX;
setClusterSize(DEFAULT_CLUSTER_SIZE);
none.addEventListener('change', () => {
setMode(MODE_NONE);
});
removeHidden.addEventListener('change', () => {
setMode(removeHidden.checked ? MODE_REMOVE : MODE_NONE);
});
clusterer.addEventListener('change', () => {
setMode(clusterer.checked ? MODE_CLUSTERER : MODE_NONE);
});
countRange.addEventListener('change', (e) => {
const cnt = +e.target.value;
setCount(cnt - (cnt % (cnt > 1000 ? 1000 : 100)));
});
clusterSizeRange.addEventListener('change', (e) => {
setClusterSize(+e.target.value);
});
}
</script>
<!-- prettier-ignore -->
<style> html, body, #app { width: 100%; height: 100%; margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; } .toolbar { position: absolute; z-index: 1000; top: 0; left: 0; display: flex; align-items: center; padding: 16px; } .toolbar a { padding: 16px; } </style>
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="./input.css" />
</head>
<body>
<div id="app">
<canvas class="fps" width="70" height="48" id="canvasRef"></canvas>
<div id="toolbar" class="toolbar options">
<div>
<div id="noneBox" class="switchers" style="order: 0">
<label class="form-check-label" for="none">Without optimizations</label>
<input class="form-check-input" type="radio" role="switch" id="none" />
</div>
<div id="removeHiddenBox" class="switchers" style="order: 1">
<label class="form-check-label" for="removeHidden">Remove hidden</label>
<input class="form-check-input" type="radio" id="removeHidden" />
</div>
<div id="clustererBox" class="switchers" style="order: 2">
<label class="form-check-label" for="clusterer">Clusterer</label>
<input class="form-check-input" type="radio" role="switch" id="clusterer" />
</div>
<div class="counter" style="order: 3">
<span class="icon"></span>
<label for="countRange" class="form-label">
Counts: <span id="countView"></span> <span id="shownView"></span>
</label>
<input-range id="countRange" />
</div>
<div id="clusterSizeRangeBox" class="counter" style="order: 4; display: none">
<style id="inlineStyle"></style>
<label for="clusterSizeRange" class="form-label">
Cluster size: <span id="clusterSizeView"></span>
</label>
<input-range type="range" class="form-range" id="clusterSizeRange" />
</div>
</div>
</div>
<div class="slow">
<div></div>
<div></div>
<div></div>
</div>
<div id="map"></div>
</div>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<script crossorigin src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/babel-standalone@6/babel.min.js"></script>
<script src="https://js.api.mappable.world/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
<script src="./variables.js"></script>
<script src="./common.js"></script>
<script type="text/babel">
window.map = null;
// CustomElements events are broken in React <=19 https://github.com/facebook/react/issues/22888
function useCustomElementSubscription(ref, event, callback) {
React.useEffect(() => {
if (!ref.current) {
return;
}
ref.current.addEventListener(event, callback);
return () => {
ref.current.removeEventListener(event, callback);
};
});
}
main();
async function main() {
const [mappableReact] = await Promise.all([mappable.import('@mappable-world/mappable-reactify'), mappable.ready]);
const reactify = mappableReact.reactify.bindTo(React, ReactDOM);
const {
MMap,
MMapDefaultSchemeLayer,
MMapFeatureDataSource,
MMapControls,
MMapMarker,
MMapLayer,
MMapListener
} = reactify.module(mappable);
const {useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo} = React;
const {MMapZoomControl} = reactify.module(await mappable.import('@mappable-world/mappable-default-ui-theme'));
const {MMapClusterer, clusterByGrid} = reactify.module(
await mappable.import('@mappable-world/mappable-clusterer')
);
ReactDOM.createRoot(document.getElementById('app')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
function Slow() {
return (
<div className="slow show">
<div />
<div />
<div />
</div>
);
}
function Switchers({mode, setMode}) {
const onToggleNone = useCallback(() => setMode(MODE_NONE), []);
const onToggleRemoveHidden = useCallback(
(e) => setMode(e.target.checked ? MODE_REMOVE : MODE_NONE),
[]
);
const onToggleClusterer = useCallback(
(e) => setMode(e.target.checked ? MODE_CLUSTERER : MODE_NONE),
[]
);
return (
<React.Fragment>
<div className="switchers" style=not_var{{order: 0}}>
<label className="form-check-label" htmlFor="none">
Without optimizations
</label>
<input
className="form-check-input"
type="radio"
id="none"
checked={mode === MODE_NONE}
onChange={onToggleNone}
/>
</div>
<div className="switchers" style={{order: 1}}>
<label className="form-check-label" htmlFor="removeHidden">
Remove hidden
</label>
<input
className="form-check-input"
type="radio"
id="removeHidden"
checked={mode === MODE_REMOVE}
onChange={onToggleRemoveHidden}
/>
</div>
<div className="switchers" style={{order: 2}}>
<label className="form-check-label" htmlFor="clasterer">
Clusterer
</label>
<input
className="form-check-input"
type="radio"
id="clasterer"
checked={mode === MODE_CLUSTERER}
onChange={onToggleClusterer}
/>
</div>
</React.Fragment>
);
}
function CountRange({count, mode, visiblePoints, setCount}) {
const ref = useRef(null);
useCustomElementSubscription(
ref,
'change',
useCallback((e) => {
const cnt = +e.target.value;
setCount(cnt - (cnt % (cnt > 1000 ? 1000 : 100)));
}, [])
);
return (
<div class="counter">
<label htmlFor="countRange" className="form-label">
Counts: {count} {mode === MODE_REMOVE ? `Shown: ${visiblePoints.length}` : ''}
</label>
<input-range
ref={ref}
type="range"
id="countRange"
min={POINTS_MIN}
max={POINTS_MAX}
step={Math.round((POINTS_MAX - POINTS_MIN) / 5)}
value={count}
/>
</div>
);
}
function ClustererOptions({mode, clusterSize, setClusterSize}) {
const ref = useRef(null);
useCustomElementSubscription(
ref,
'change',
useCallback((e) => setClusterSize(+e.target.value), [])
);
return (
<div id="clusterSizeRangeBox" class="counter" style={{display: 'none'}}>
<style>
{`:root {
--radius: ${(clusterSize / 3) * 20}px
}`}
</style>
<label htmlFor="clusterSizeRange" className="form-label">
Cluster size: {Math.pow(2, clusterSize)}
</label>
<input-range
ref={ref}
type="range"
id="clusterSizeRange"
min={CLUSTER_SIZE_MIN}
max={CLUSTER_SIZE_MAX}
step={1}
value={clusterSize}
/>
</div>
);
}
function App() {
const canvasRef = useRef(null);
const [slow, toggleSlow] = useState(true);
const [location, setLocation] = useState(LOCATION);
const [modeSlow, setModeSlow] = useState(MODE);
const [mode, setMode] = useState(MODE);
const [count, setCount] = useState(DEFAULT_COUNT);
const [points, setPoints] = useState([]);
const debounceSetPoints = useMemo(() => debounce((...args) => setPoints(...args), 300), []);
const [clusterSize, setClusterSize] = useState(DEFAULT_CLUSTER_SIZE);
const [gridSizedMethod, setGridSizedMethod] = useState(() =>
clusterByGrid({gridSize: Math.pow(2, clusterSize)})
);
useLayoutEffect(() => {
startDrawFPS(canvasRef.current);
}, []);
useEffect(() => {
updateUrl(count, location, mode, clusterSize);
}, [count, location, mode, clusterSize]);
useEffect(() => {
setMode(modeSlow);
toggleSlow(true);
requestIdleCallback(() => toggleSlow(false));
}, [modeSlow, count]);
useEffect(() => {
debounceSetPoints((points) => getPointListSync(count, points));
}, [count]);
useEffect(() => {
setGridSizedMethod(clusterByGrid({gridSize: Math.pow(2, clusterSize)}));
}, [clusterSize]);
const onUpdate = useCallback(({location}) => setLocation(location), []);
const onResize = useCallback(() => setLocation({center: map.center, zoom: map.zoom}), []);
const marker = useCallback(
(p) => (
<MMapMarker
key={p.id + '-' + p.geometry.coordinates.toString()}
source="marker-source"
coordinates={p.geometry.coordinates}
markerElement={p.markerElement[mode] || p.markerElement[MODE_NONE]}
/>
),
[mode]
);
const cluster = useCallback(
(coordinates, features) => (
<MMapMarker
key={`${features[0].id}-${features.length}`}
coordinates={coordinates}
source="marker-source"
>
<div className="circle">
<div className="circle-content">
<span className="circle-text">{features.length}</span>
</div>
</div>
</MMapMarker>
),
[]
);
const bounds = useMemo(() => map && map.bounds, [location]);
let visiblePoints = points;
if (mode === MODE_REMOVE) {
visiblePoints = visiblePoints.filter((p) => isVisible(p, bounds));
}
return (
<React.Fragment>
{slow && <Slow />}
<canvas className="fps" width={70} height={48} ref={canvasRef} />
<div
className={[
'toolbar',
'options',
SHOW_MODE_SWITCHERS ? 'show-switchers' : null,
`mode_${mode}`
]
.filter(Boolean)
.join(' ')}
>
<div>
<Switchers mode={modeSlow} setMode={setModeSlow} />
<CountRange
count={count}
mode={mode}
visiblePoints={visiblePoints}
setCount={setCount}
/>
<ClustererOptions
mode={mode}
clusterSize={clusterSize}
setClusterSize={setClusterSize}
/>
</div>
</div>
<MMap location={location} zoomRange={ZOOM_RANGE} ref={(x) => (map = x)}>
<MMapListener onUpdate={onUpdate} onResize={onResize} />
<MMapDefaultSchemeLayer />
<MMapControls position="right">
<MMapZoomControl />
</MMapControls>
<MMapFeatureDataSource id="marker-source" />
<MMapLayer source="marker-source" type="markers" />
{mode !== MODE_CLUSTERER && bounds && visiblePoints && visiblePoints.map(marker)}
{mode === MODE_CLUSTERER && (
<MMapClusterer
marker={marker}
cluster={cluster}
method={gridSizedMethod}
features={points}
/>
)}
</MMap>
</React.Fragment>
);
}
}
</script>
<!-- prettier-ignore -->
<style> html, body, #app { width: 100%; height: 100%; margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; } .toolbar { position: absolute; z-index: 1000; top: 0; left: 0; display: flex; align-items: center; padding: 16px; } .toolbar a { padding: 16px; } </style>
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="./input.css" />
</head>
<body>
<div id="app"></div>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
<script crossorigin src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<script src="https://js.api.mappable.world/v3/?apikey=<YOUR_APIKEY>&lang=en_US" type="text/javascript"></script>
<script src="./variables.js"></script>
<script src="./common.js"></script>
<script></script>
<!-- prettier-ignore -->
<style> html, body, #app { width: 100%; height: 100%; margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; } .toolbar { position: absolute; z-index: 1000; top: 0; left: 0; display: flex; align-items: center; padding: 16px; } .toolbar a { padding: 16px; } </style>
<link rel="stylesheet" href="./common.css" />
<link rel="stylesheet" href="./input.css" />
</head>
<body>
<style id="inlineStyle"></style>
<div id="app"></div>
</body>
</html>
#map {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-family: 'Mappable Sans Text', Arial, Helvetica, sans-serif;
}
:root {
--radius: 40px;
--point-border-color: #fff;
--point-bg-color: #313133;
--point-size: 20px;
--toolbar-offset: 12px;
--toolbar-shadow: 0 0 10px 0 #0000001a;
}
.options {
background-color: #fff;
border-radius: var(--toolbar-offset);
box-shadow: var(--toolbar-shadow);
top: var(--toolbar-offset);
left: var(--toolbar-offset);
padding: 0;
font-size: 14px;
font-weight: 500;
}
.options.show-switchers > div {
display: flex;
flex-direction: column;
}
.fps {
position: absolute;
right: var(--toolbar-offset);
top: var(--toolbar-offset);
z-index: 30000;
background-color: #eefd7c;
border-radius: var(--toolbar-offset);
box-shadow: var(--toolbar-shadow);
height: 48px;
}
.point {
width: var(--point-size);
height: var(--point-size);
transform: translate(-50%, -50%);
border-radius: 9px;
border: 2px solid var(--point-border-color);
background-color: var(--point-bg-color);
}
.point:after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
background-color: var(--point-border-color);
border-radius: 2px;
transform: translate(-50%, -50%);
}
.count {
display: inline-block;
padding: 0 8px;
}
.circle {
position: relative;
width: var(--radius, 20px);
height: var(--radius, 20px);
color: #f2f5fa;
border: 2px solid #fff;
border-radius: 50%;
background-color: #313133;
transform: translate(-50%, -50%);
box-shadow: 0 0 1.891838788986206px 0 #5f698314;
}
.circle-content {
position: absolute;
top: 50%;
left: 50%;
display: flex;
justify-content: center;
align-items: center;
width: 70%;
height: 70%;
border-radius: 50%;
transform: translate3d(-50%, -50%, 0);
}
.circle-text {
font-size: 0.9em;
color: #fff;
}
.slow {
display: inline-block;
width: 80px;
height: 80px;
position: absolute;
z-index: 2000;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
opacity: 0.8;
}
.slow div {
display: inline-block;
position: absolute;
left: 8px;
width: 16px;
background: #fff;
animation: slow 1.2s cubic-bezier(0, 0.5, 0.5, 1) infinite;
}
.slow div:nth-child(1) {
left: 8px;
animation-delay: -0.24s;
}
.slow div:nth-child(2) {
left: 32px;
animation-delay: -0.12s;
}
.slow div:nth-child(3) {
left: 56px;
animation-delay: 0s;
}
.slow {
display: none;
}
.slow.show {
display: block;
}
@keyframes slow {
0% {
top: 8px;
height: 64px;
}
50%,
100% {
top: 24px;
height: 32px;
}
}
.counter {
display: flex;
align-items: center;
font-weight: 500;
padding: 0 16px;
margin-top: 16px;
margin-bottom: 16px;
}
.show-switchers .counter {
font-size: 10px;
color: #808187;
margin-bottom: 4px;
margin-top: 4px;
position: relative;
top: -6px;
}
.switchers {
justify-content: space-between;
display: flex;
align-items: center;
min-height: 47px;
font-weight: 500;
padding: 0 16px;
}
.counter label,
.switchers label {
flex: 1;
}
.counter .icon {
margin-right: 8px;
width: 16px;
height: 16px;
background: url(./point.svg) no-repeat;
}
.toolbar.show-switchers .counter .icon,
.toolbar.mode_clusterer .counter .icon {
display: none;
}
.counter label {
margin-right: 8px;
min-width: 112px;
}
.toolbar.mode_remove .counter label {
min-width: 185px;
}
.toolbar:not(.show-switchers) .switchers {
display: none;
}
.toolbar.mode_clusterer #clusterSizeRangeBox {
display: flex !important;
}
.toolbar.mode_none .counter {
order: 0 !important;
}
.toolbar.mode_remove .counter {
order: 1 !important;
}
.toolbar.mode_clusterer .counter {
order: 2 !important;
}
.toolbar.mode_clusterer.show-switchers {
padding-bottom: 16px;
}
const POINTS_MIN = 200;
const POINTS_MAX = 30200;
const CLUSTER_SIZE_MIN = 6;
const CLUSTER_SIZE_MAX = 11;
const MODE_NONE = 'none';
const MODE_REMOVE = 'remove';
const MODE_CLUSTERER = 'clusterer';
const SEARCH_PARAMS = new URLSearchParams(window.location.search);
const DELTA_LENGTH = SEARCH_PARAMS.get('delta') ? +SEARCH_PARAMS.get('delta') : 5;
const LOCATION = {
center: SEARCH_PARAMS.get('center') ? SEARCH_PARAMS.get('center').split(',').map(Number) : FIXED_POINT,
zoom: SEARCH_PARAMS.get('zoom') ? +SEARCH_PARAMS.get('zoom') : 7
};
const ZOOM_MIN = Math.max(SEARCH_PARAMS.get('zoomMin') ? +SEARCH_PARAMS.get('zoomMin') : 5, 5);
const ZOOM_MAX = Math.min(SEARCH_PARAMS.get('zoomMax') ? +SEARCH_PARAMS.get('zoomMax') : 19, 21);
const ZOOM_RANGE = {min: ZOOM_MIN, max: ZOOM_MAX};
const DEFAULT_COUNT = SEARCH_PARAMS.get('count') ? +SEARCH_PARAMS.get('count') : POINTS_MIN;
const DEFAULT_CLUSTER_SIZE = SEARCH_PARAMS.get('clusterSize') ? +SEARCH_PARAMS.get('clusterSize') : CLUSTER_SIZE_MIN;
const MODE = [MODE_NONE, MODE_REMOVE, MODE_CLUSTERER].includes(SEARCH_PARAMS.get('mode'))
? SEARCH_PARAMS.get('mode')
: MODE_NONE;
const SHOW_MODE_SWITCHERS = Boolean(SEARCH_PARAMS.get('showMode'));
const MARKER_ELEMENT = document.createElement('div');
MARKER_ELEMENT.classList.add('point');
function getMarkerElement(i) {
return MARKER_ELEMENT.cloneNode(true);
}
const CHUNK_SIZE = 1000;
/**
* Generator returns a random set of count points around the center of the map
* @param {number} count
* @param {Array} cacheList
*/
function* getPointList(count, cacheList) {
if (cacheList.length > count) {
yield cacheList.slice(0, count);
return;
}
const result = [...cacheList];
for (let i = cacheList.length; i < count; i += 1) {
result.push({
type: 'Feature',
id: i,
geometry: {
coordinates: getRandomPoint()
},
/**
* Elements are divided into modes, since they cannot be used
* both in the clusterer and in direct output at the same time
*/
markerElement: {
[MODE_NONE]: getMarkerElement(i),
[MODE_CLUSTERER]: getMarkerElement(i)
}
});
if (i % CHUNK_SIZE === 0) {
yield result;
}
}
yield result;
}
function getPointListSync(count, cacheList) {
const gen = getPointList(count, cacheList);
let result = [];
do {
const {value, done} = gen.next();
if (done) {
break;
}
result = value;
} while (true);
return result;
}
const seed = (s) => () => {
s = Math.sin(s) * 10000;
return s - Math.floor(s);
};
const rnd = seed(10000);
function getRandomPoint() {
const [x, y] = LOCATION.center;
return [x + (rnd() > 0.5 ? -1 : 1) * rnd() * DELTA_LENGTH * 2, y + (rnd() > 0.5 ? -1 : 1) * rnd() * DELTA_LENGTH];
}
function runFPSCounter(cb) {
const times = [];
const timeoutShow = 300;
let lastTime = 0;
function fpsCounter() {
requestAnimationFrame(() => {
const now = performance.now();
while (times.length > 0 && times[0] <= now - 1000) {
times.shift();
}
times.push(now);
const fps = times.length;
if (lastTime + timeoutShow < now) {
cb(fps);
lastTime = now;
}
fpsCounter();
});
}
fpsCounter();
}
/**
* Draws the current FPS(Frame Per Second) value on the canvas
* @param {HTMLCanvasElement} canvas
*/
function startDrawFPS(canvas) {
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1,
bsr =
ctx.webkitBackingStorePixelRatio ||
ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio ||
ctx.oBackingStorePixelRatio ||
ctx.backingStorePixelRatio ||
1;
const width = canvas.width;
const height = canvas.height;
if (dpr !== bsr) {
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx.scale(dpr, dpr);
}
ctx.font = '500 14px Arial';
runFPSCounter((fps) => {
ctx.clearRect(0, 0, width, height);
const widthText = ctx.measureText(fps + ' fps').width;
ctx.fillText(fps + ' fps', width / 2 - widthText / 2, 29);
});
}
/**
* Returns a function that will only be called once for all of its calls in the delay period
* @param {Function} cb
* @param {number} delay
* @returns {(function(...[*]): void)|*}
*/
function debounce(cb, delay) {
let timer = 0;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => cb(...args), delay);
};
}
function throttle(func, ms, ctx) {
let isThrottled = false;
let savedArgs = null;
function wrapper() {
if (isThrottled) {
savedArgs = arguments;
return;
}
func.apply(ctx, arguments);
isThrottled = true;
setTimeout(() => {
isThrottled = false;
if (savedArgs) {
wrapper.apply(ctx, savedArgs);
savedArgs = null;
}
}, ms);
}
return wrapper;
}
/**
* Checks if the point is within the visible area of the map
* @param {[number,number]} coordinates
* @param {[[number,number], [number,number]]} bounds
* @returns {boolean}
*/
function isVisible({geometry: {coordinates}}, bounds) {
if (!bounds) {
return true;
}
const [x, y] = coordinates;
const [[x1, y1], [x2, y2]] = bounds;
return x >= x1 && x <= x2 && y >= y2 && y <= y1;
}
const updateUrl = debounce((count, location, mode, clusterSize) => {
SEARCH_PARAMS.set('clusterSize', clusterSize);
SEARCH_PARAMS.set('count', count);
SEARCH_PARAMS.set('center', location.center.map((c) => c.toFixed(10)).toString());
SEARCH_PARAMS.set('zoom', location.zoom.toFixed(0));
SEARCH_PARAMS.set('mode', mode);
const newRelativePathQuery = window.location.pathname + '?' + SEARCH_PARAMS.toString();
history.replaceState(null, '', newRelativePathQuery);
}, 300);
function circle(count) {
const circle = document.createElement('div');
circle.classList.add('circle');
circle.innerHTML = `
<div class="circle-content">
<span class="circle-text">${count}</span>
</div>
`;
return circle;
}
class CustomRange extends HTMLElement {
static observedAttributes = ['value', 'max', 'min', 'step'];
get value() {
return this.#input.value;
}
set value(v) {
this.#input.value = v;
this.#updateLine();
}
get step() {
return parseInt(this.#input.step, 10);
}
set step(v) {
this.#input.step = v > 1 ? v : 1;
this.#updateLine();
this.#fillBalls();
}
get max() {
return parseInt(this.#input.max, 10);
}
set max(v) {
this.#input.max = v;
this.#updateLine();
this.#fillBalls();
}
get min() {
return parseInt(this.#input.min, 10);
}
set min(v) {
this.#input.min = v;
this.#updateLine();
this.#fillBalls();
}
constructor() {
super();
['change', 'input'].forEach((event) => {
this.#input.addEventListener(event, (e) => {
this.#updateLine();
this.dispatchEvent(
new Event(event, {
bubbles: true
})
);
});
});
this.#input.type = 'range';
this.#line.classList.add('input-line');
this.#wrapper.classList.add('input-wrapper');
fetch('./input.css').then(
(resp) => {
resp.text().then((text) => {
this.#style.textContent = text;
});
},
() => null
);
}
#updateLine() {
const {min, max, value} = this;
const range = max - min;
const start = value - min;
this.#wrapper.style.setProperty('--input-line-value', `${(start / range) * 100}%`);
}
attributeChangedCallback(key, _, value) {
this.#input[key] = value;
this.#updateLine();
}
#style = document.createElement('style');
#line = document.createElement('span');
#wrapper = document.createElement('span');
#input = document.createElement('input');
connectedCallback() {
const props = Object.values(this.attributes);
props.forEach((attr) => {
this.#input.setAttribute(attr.name, attr.value);
});
const shadow = this.attachShadow({mode: 'open'});
shadow.appendChild(this.#wrapper);
this.#wrapper.appendChild(this.#input);
this.#wrapper.appendChild(this.#line);
this.#wrapper.appendChild(this.#style);
this.#fillBalls();
this.#updateLine();
}
#fillBalls() {
this.#line.innerHTML = '';
for (let i = this.min; i <= this.max; i += this.step) {
const ball = document.createElement('span');
ball.classList.add('ball');
this.#line.appendChild(ball);
}
}
}
customElements.define('input-range', CustomRange);
mappable.ready.then(() => {
mappable.import.registerCdn('https://cdn.jsdelivr.net/npm/{package}', [
'@mappable-world/mappable-default-ui-theme@0.0',
'@mappable-world/mappable-clusterer@0.0'
]);
});
/* https://www.smashingmagazine.com/2021/12/create-custom-range-input-consistent-browsers/ */
:root {
--track-width: 150px;
--track-height: 2px;
--thumb-size: 10px;
--track-bg-color: #122db2;
--track-bg-image: linear-gradient(var(--track-bg-color), var(--track-bg-color));
--thumb-bg-color: #fff;
--thumb-box-shadow: 0 2px 6px 0 #00000033;
--input-line-value: 0;
}
/********** Range Input Styles **********/
/*Range Reset*/
input[type='range'] {
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
width: var(--track-width);
margin: 0;
background: transparent;
background-image: var(--track-bg-image);
background-repeat: no-repeat;
background-size: var(--input-line-value, 0) 100%;
border-radius: 1000px;
z-index: 2;
height: var(--track-height);
}
/* Removes default focus */
input[type='range']:focus {
outline: none;
}
/***** Chrome, Safari, Opera and Edge Chromium styles *****/
/* slider track */
input[type='range']::-webkit-slider-runnable-track {
border-radius: var(--track-height);
height: var(--track-height);
}
/* slider thumb */
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none; /* Override default look */
appearance: none;
margin-top: calc(var(--thumb-size) / -2 + var(--track-height) / 2); /* Centers the thumb vertically */
/*custom styles*/
background-color: var(--thumb-bg-color);
height: var(--thumb-size);
width: var(--thumb-size);
border-radius: 50%;
box-shadow: var(--thumb-box-shadow);
position: relative;
z-index: 2;
}
/******** Firefox styles ********/
/* slider track */
input[type='range']::-moz-range-track {
border-radius: var(--track-height);
height: var(--track-height);
}
/* slider thumb */
input[type='range']::-moz-range-thumb {
border: none;
border-radius: 50%;
/*custom styles*/
background-color: var(--thumb-bg-color);
height: var(--thumb-size);
width: var(--thumb-size);
box-shadow: var(--thumb-box-shadow);
}
.input-line {
height: 2px;
position: absolute;
width: 100%;
left: 0;
top: calc(50% - 1px);
background-repeat: repeat-x;
background-size: 20px 2px;
background-position: right;
z-index: 1;
display: flex;
justify-content: space-between;
}
.ball {
display: inline-block;
width: 2px;
height: 2px;
background-color: #d0d3d6;
border-radius: 50%;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
input[type='radio'] {
box-sizing: border-box;
margin: 0;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #d9dbdf;
background-color: #fff;
}
input[type='radio']:checked {
border-color: #122db2;
border-width: 3px;
}
const FIXED_POINT = [55.44279, 25.24613];