Skip to content

Commit

Permalink
Travel time area
Browse files Browse the repository at this point in the history
  • Loading branch information
LOOHP committed Nov 30, 2024
1 parent 72000ea commit 035afba
Show file tree
Hide file tree
Showing 4 changed files with 126 additions and 10 deletions.
2 changes: 1 addition & 1 deletion custom_heatmap_leaflet.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
return this._colorize(n.data, this._grad), i.putImageData(n, 0, 0), this
},
_colorize: function(t, i) {
for (var a, s = 3, e = t.length; e > s; s += 4) {
for (let a, s = 3, e = t.length; e > s; s += 4) {
a = 4 * t[s];
if (a) {
t[s - 3] = i[a]; // Red channel
Expand Down
18 changes: 17 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster/dist/MarkerCluster.Default.css" />
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster/dist/leaflet.markercluster.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@7/turf.min.js"></script>
<script src="custom_heatmap_leaflet.js"></script>
<script src="kdtree.js"></script>
<script src="filesaver.js"></script>
Expand Down Expand Up @@ -163,7 +164,7 @@
</div>

<div class="field">
<label class="label" for="maxInterchanges">最多轉車次數 (鐵路除外)<br>Max Interchanges (Excl. MTR)</label>
<label class="label" for="maxInterchanges">最多轉車次數<br>Max Interchanges</label>
<div class="control">
<input type="number" class="input" id="maxInterchanges" name="maxInterchanges" min="0" max="3" value="1" onchange="reload();">
</div>
Expand Down Expand Up @@ -231,8 +232,23 @@
</div>
</div>

<div class="field">
<label class="label" for="useArea">行程時間範圍<br>Travel Time Area</label>
<div class="control">
<div class="select">
<select name="language" id="useArea" onchange="reload();">
<option value="true">啟用 Enabled</option>
<option value="false" selected>停用 Disabled</option>
</select>
</div>
</div>
<p class="note">此選項可能會增加載入時間<br>This option might significantly increase loading times.</p>
</div>

<button class="export-image-button button is-info" id="export-image-button" onclick="exportHeatmapAsImage();" disabled>匯出熱圖 Export Heatmap (PNG)</button>

<button class="export-area-button button is-info" id="export-area-button" onclick="exportTimeArea();" disabled>匯出時間範圍 Export Area (GeoJson)</button>

<button class="export-points-button button is-info" id="export-points-button" onclick="exportGeoJson();" disabled>匯出數據點 Export Points (GeoJson)</button>
<p class="note">Note that it might require <b>ArcGis Pro</b> to recreate the best look and feel of the heatmap if you are using ArcGis</p>
</div>
Expand Down
106 changes: 100 additions & 6 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ function reload() {
intensityByTravelTimeMaxTime = Number(document.getElementById("intensityByTravelTimeMaxTime").value);
maxTransparency = Number(document.getElementById("maxTransparency").value);
clipToBoundaries = document.getElementById("boundaries").value === "true";
enableAreaLayer = document.getElementById("useArea").value === "true";
updateHeatLegend(intensityByTravelTimeMaxTime);
if (!lastPosition) return;
const [lat, lng] = lastPosition;
Expand Down Expand Up @@ -171,6 +172,11 @@ function calculateWalkTimeByDistance(distance) {
return distance / walkingSpeedInKmPerSecond;
}

// Calculate intensity based on travel time
function calculateIntensityByTravelTime(travelTime) {
return Math.max(0, 1 - (travelTime / 60) / intensityByTravelTimeMaxTime);
}

// Haversine formula to calculate the distance between two points (in kilometers)
function getDistanceFromLatLngInKm(lat1, lng1, lat2, lng2) {
const R = 6371; // Radius of the Earth in km
Expand Down Expand Up @@ -208,11 +214,6 @@ function findStopsWithinRadius(stopList, targetLat, targetLng, radiusKm) {
return stopsWithinRadius;
}

// Calculate intensity based on travel time
function calculateIntensityByTravelTime(travelTime) {
return Math.max(0, 1 - (travelTime / 60) / intensityByTravelTimeMaxTime);
}

async function generateHeatmapDataWithTravelDistance(stopList, routeList, startStops, seenRoutes = new Set()) {
const stopSequenceList = [];
const nextSeenRouts = [];
Expand Down Expand Up @@ -358,10 +359,13 @@ function mergeHeatmapData(map1, map2) {
async function updateOrigin(lat = lastPosition[0], lng = lastPosition[1]) {
document.getElementById("export-points-button").disabled = true;
document.getElementById("export-image-button").disabled = true;
document.getElementById("export-area-button").disabled = true;

if (!routeList || !stopList || !lat || !lng) return;
droppedPinLayer.clearLayers();
transitPointLayer.clearLayers();
areaLayer.clearLayers();
lastAreaGeoJson = null;
L.marker([lat, lng]).addTo(droppedPinLayer);
document.getElementById("origin").innerHTML = `${lat.toFixed(5)}, ${lng.toFixed(5)}`;

Expand Down Expand Up @@ -423,10 +427,65 @@ async function updateOrigin(lat = lastPosition[0], lng = lastPosition[1]) {
});
}

if (enableAreaLayer) {
const timeIntervals = [];
for (let min = 10; min <= intensityByTravelTimeMaxTime; min += 10) {
timeIntervals.push(min * 60);
}
const travelTimePolygons = generateTravelTimePolygon(timeIntervals);
for (const [time, polygon] of Object.entries(travelTimePolygons).toReversed()) {
const polygonLayer = L.geoJSON(polygon, {
style: {
color: 'black',
fillOpacity: 0,
weight: 2
}
}).addTo(areaLayer);
polygonLayer.bindTooltip(`${time / 60} ${language === "en" ? " Mins Area" : "分鐘範圍"}`, {sticky: true});
polygonLayer.on('click', e => {
e.target.openTooltip(e.latlng); // Open the tooltip at the clicked location
});
}
lastAreaGeoJson = travelTimePolygons;
}

if (journeyTimesData.length > 0) {
document.getElementById("export-points-button").disabled = false;
document.getElementById("export-image-button").disabled = false;
if (lastAreaGeoJson) {
document.getElementById("export-area-button").disabled = false;
}
}
}

function generateTravelTimePolygon(timeIntervals) {
const reachablePointsByInterval = {};
for (let lat = hongKongBounds.minLat; lat <= hongKongBounds.maxLat; lat += gridResolutionLat) {
for (let lng = hongKongBounds.minLng; lng <= hongKongBounds.maxLng; lng += gridResolutionLng) {
const {time} = getMinTimeAt(lat, lng);
if (time !== null) {
for (const timeInterval of timeIntervals) {
if (time <= timeInterval) {
if (!reachablePointsByInterval.hasOwnProperty(timeInterval)) {
reachablePointsByInterval[timeInterval] = [];
}
reachablePointsByInterval[timeInterval].push([lat, lng]);
}
}
}
}
}
const result = {};
for (const [timeInterval, reachablePoints] of Object.entries(reachablePointsByInterval)) {
const points = turf.featureCollection(
reachablePoints.map(([lat, lng]) => turf.point([lng, lat]))
);
let polygon = turf.concave(points, { maxEdge: 1 });
polygon = turf.polygonSmooth(polygon, { iterations: 3 });
polygon.features.forEach(f => f.properties["journeyTime"] = timeInterval);
result[timeInterval] = polygon;
}
return result;
}

function getMinTimeAt(lat, lng) {
Expand Down Expand Up @@ -490,6 +549,29 @@ function exportGeoJson() {
downloadGeoJSON(geojson);
}

function exportTimeArea() {
if (!lastAreaGeoJson) {
return
}
let geojson = {
type: "FeatureCollection",
features: Object.values(lastAreaGeoJson).toReversed().flatMap(e => e.features),
}
// Download the GeoJSON file
const downloadGeoJSON = (geojson) => {
const blob = new Blob([JSON.stringify(geojson, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'area.geojson';
link.click();
};
// Call the download function
downloadGeoJSON(geojson);
}

function exportHeatmapAsImage() {
const width = heatmapLayer._heat._width;
const height = heatmapLayer._heat._height;
Expand Down Expand Up @@ -529,6 +611,7 @@ loadJSON("./district_boundaries.geojson", geoJson => {
let lastPosition = null;
let lastJourneyTimes = [];
let lastJourneyTimesTree = null;
let lastAreaGeoJson = null;

let language = "zh";
let basemapUrl = "";
Expand All @@ -544,6 +627,14 @@ let interchangeTimes = 900;
let interchangeTimeForTrains = 90;
let walkableDistance = 1.5;
let clipToBoundaries = false;
let enableAreaLayer = false;

const hongKongBounds = {
minLat: 22.14, maxLat: 22.57,
minLng: 113.83, maxLng: 114.43
};
const gridResolutionLat = 0.0009; // 100 meters in latitude
const gridResolutionLng = 0.00097; // 100 meters in longitude

const redIcon = L.icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
Expand Down Expand Up @@ -572,10 +663,13 @@ const transitPointLayer = L.markerClusterGroup({spiderfyOnMaxZoom: false, disabl
const heatmapLayer = L.heatLayer([], {radius: 20, blur: 20, maxZoom: 17, gradient: gradient}).addTo(map);
drawHeatLegend();

const areaLayer = L.layerGroup().addTo(map);

const layerControl = L.control.layers(null, null).addTo(map)
.addOverlay(droppedPinLayer, "所選地點 Selected Location")
.addOverlay(transitPointLayer, "車站地點 Transit Points")
.addOverlay(heatmapLayer, "熱圖 Heatmap");
.addOverlay(heatmapLayer, "熱圖 Heatmap")
.addOverlay(areaLayer, "行程時間範圍 Travel Time Area");

reload();

Expand Down
10 changes: 8 additions & 2 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,19 @@ html {

.export-image-button {
width: 100%;
font-size: 15px;
font-size: 14px;
margin-bottom: 10px;
}

.export-points-button {
width: 100%;
font-size: 15px;
font-size: 14px;
}

.export-area-button {
width: 100%;
font-size: 14px;
margin-bottom: 10px;
}

.heat-legend {
Expand Down

0 comments on commit 035afba

Please sign in to comment.