Để tạo hiệu ứng đường bay của các vì sao băng trên bản đồ Leaflet, chúng ta có thể tùy chỉnh một lớp di chuyển dữ liệu hiện có. Bài viết này sẽ hướng dẫn cách triển khai và cấu hình lớp này, bao gồm các tham số quan trọng.
Tệp HTML
Trước tiên, cần chuẩn bị tệp HTML để nhúng bản đồ và các tập lệnh cần thiết.
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title>Hiệu ứng Sao Băng trên Leaflet</title>
<link rel="stylesheet" href="./leaflet/leaflet.css">
<style>
html, body {
height: 100%;
margin: 0;
}
.leaflet-container {
height: 100vh;
width: 100%;
max-width: 100%;
max-height: 100%;
}
.my-div-icon {
background-color: rgb(10, 214, 214);
border-radius: 50%;
width: 10px;
height: 10px;
}
</style>
</head>
<body>
<div id="map"></div>
<script src="/leaflet/leaflet.js"></script>
<script src="/leaflet/migrationLayer.js"></script>
<script>
// Cấu hình bản đồ cơ sở
const mapProviderUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
const baseTileLayer = new L.TileLayer(mapProviderUrl);
const initialCenter = new L.latLng(39.5, 116.89);
const leafletMap = new L.Map("map", {
CRS: 'Simple',
center: initialCenter,
zoom: 6,
layers: [baseTileLayer],
minZoom: 0,
maxZoom: 16,
opacity: 0.8,
});
// Hàm tạo tọa độ ngẫu nhiên
const generateRandomCoords = () => {
const latitude = Math.random() * 45; // Giới hạn vĩ độ
const longitude = Math.random() * 150; // Giới hạn kinh độ
return [longitude, latitude];
}
// Hàm tạo màu HEX ngẫu nhiên
const generateRandomHexColor = () => {
const hexChars = '0123456789ABCDEF';
let colorCode = '#';
for (let i = 0; i < 6; i++) {
colorCode += hexChars[Math.floor(Math.random() * 16)];
}
return colorCode;
}
// Dữ liệu cho các dòng di chuyển
const migrationData = [];
for (let i = 0; i < 10; i++) {
migrationData.push({
color: generateRandomHexColor(), // Màu của sao băng
from: generateRandomCoords(), // Điểm bắt đầu
to: generateRandomCoords(), // Điểm kết thúc
labels: ["Điểm A", "Điểm B"], // Nhãn cho điểm bắt đầu và kết thúc
beArrow: false, // Có hiển thị mũi tên không (mũi tên dẫn đường và mũi tên kết thúc)
bePulse: true, // Có hiển thị vòng tròn lan tỏa ở điểm kết thúc không
arrowSize: 0.1, // Kích thước mũi tên
});
}
// Khởi tạo lớp MigrationLayer
const migrationOverlay = new L.migrationLayer({
map: leafletMap,
data: migrationData,
pulseRadius: 5, // Bán kính của vòng tròn lan tỏa
pulseColor: '#f00f00', // Màu của vòng tròn lan tỏa
pulseBorderWidth: 1, // Độ rộng viền của vòng tròn lan tỏa
starWidth: 4, // Độ rộng của vệt sao băng
arcWidth: 2, // Độ rộng của đường cong (vệt sao băng)
arcLabel: false, // Có hiển thị nhãn trên đường cong không
arcAlpha: 0.5, // Độ trong suốt của đường cong
arcStrokeColor: '#f00f00', // Màu của đường cong
arcLabelFont: '10px sans-serif', // Phông chữ cho nhãn đường cong
});
migrationOverlay.addTo(); // Thêm lớp vào bản đồ
// Xử lý sự kiện click trên đường cong
migrationOverlay.onClick((arcData) => {
console.log('Đường cong được nhấp vào:', arcData);
});
</script>
</body>
</html>
Tệp migrationLayer.js
Tệp này chứa mã nguồn của lớp L.MigrationLayer, bao gồm các thành phần để vẽ các đối tượng đồ họa như điểm đánh dấu, đường cong, vòng tròn lan tỏa và vệt sao băng.
(function (window) {
// Các hàm tiện ích để xử lý màu sắc và mảng
const utils = {
calculateColor: function (color, opacity) {
if (color.indexOf('#') === 0) {
const hexColor = color.slice(1);
const r = parseInt(hexColor.slice(0, 2), 16);
const g = parseInt(hexColor.slice(2, 4), 16);
const b = parseInt(hexColor.slice(4), 16);
return `rgba(${r},${g},${b},${opacity})`;
} else if (/^rgb\(/.test(color)) {
return color.replace(/rgb/, 'rgba').replace(')', `,${opacity})`);
} else {
return color.split(',').slice(0, 3).join(',') + `,${opacity})`;
}
}
};
const arrayUtils = {
forEach: function (arr, callback, scope) {
if (typeof Array.prototype.forEach === 'function') {
arr.forEach(callback, scope);
} else {
for (let i = 0, len = arr.length; i < len; i++) {
callback.apply(scope, [arr[i], i, arr]);
}
}
},
map: function (arr, callback, scope) {
if (typeof Array.prototype.map === 'function') {
return arr.map(callback, scope);
} else {
const mappedArray = [];
for (let i = 0, len = arr.length; i < len; i++) {
mappedArray[i] = callback.apply(scope, [arr[i], i, arr]);
}
return mappedArray;
}
}
};
// Lớp Marker để vẽ các điểm đánh dấu và mũi tên
const Marker = (function () {
function Marker(options) {
this.x = options.x;
this.y = options.y;
this.rotation = options.rotation;
this.style = options.style; // 'circle' hoặc 'arrow'
this.color = options.color;
this.size = options.size;
this.borderWidth = options.borderWidth;
this.borderColor = options.borderColor;
}
Marker.prototype.draw = function (context) {
context.save();
context.translate(this.x, this.y);
context.rotate(this.rotation);
context.lineWidth = this.borderWidth || 0;
context.strokeStyle = this.borderColor || '#000';
context.fillStyle = this.color || '#000';
context.beginPath();
if (this.style === 'circle') {
context.arc(0, 0, this.size, 0, Math.PI * 2, false);
} else if (this.style === 'arrow') {
context.moveTo(-this.size, -this.size);
context.lineTo(this.size, 0);
context.lineTo(-this.size, this.size);
context.lineTo(-this.size / 4, 0);
context.lineTo(-this.size, -this.size);
}
context.closePath();
context.stroke();
context.fill();
context.restore();
};
return Marker;
})();
// Lớp Arc để vẽ đường cong
const Arc = (function () {
function Arc(options) {
const startX = options.startX, startY = options.startY, endX = options.endX, endY = options.endY;
// Tính toán tâm và bán kính của cung tròn để tạo hiệu ứng cong
const dx = endX - startX;
const dy = endY - startY;
const dist = Math.sqrt(dx * dx + dy * dy);
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2;
// Hệ số để điều chỉnh độ cong của cung
const curvatureFactor = 1.5;
// Tính toán tâm cung tròn
const centerX = midX - dy * curvatureFactor * dist / 100;
const centerY = midY + dx * curvatureFactor * dist / 100;
// Tính bán kính cung tròn
const radius = Math.sqrt(Math.pow(dist / 2, 2) + Math.pow(dist * curvatureFactor / 10, 2));
const startAngle = Math.atan2(startY - centerY, startX - centerX);
const endAngle = Math.atan2(endY - centerY, endX - centerX);
this.startX = startX;
this.startY = startY;
this.endX = endX;
this.endY = endY;
this.centerX = centerX;
this.centerY = centerY;
this.startAngle = startAngle;
this.endAngle = endAngle;
this.startLabel = options.labels && options.labels[0];
this.endLabel = options.labels && options.labels[1];
this.radius = radius;
this.lineWidth = options.width || 1;
this.strokeStyle = options.color || '#000';
this.arcAlpha = options.arcAlpha || .2;
this.arcStrokeColor = options.arcStrokeColor || '#000';
this.hasLabel = options.label;
this.font = options.font;
}
Arc.prototype.draw = function (context) {
context.save();
context.lineWidth = this.lineWidth;
context.strokeStyle = this.strokeStyle;
context.globalAlpha = this.arcAlpha;
context.shadowColor = this.strokeStyle;
context.lineCap = 'round';
context.beginPath();
context.arc(this.centerX, this.centerY, this.radius, this.startAngle, this.endAngle, false);
context.stroke();
context.restore();
// Vẽ nhãn nếu có
context.save();
context.fillStyle = this.strokeStyle;
if (this.hasLabel) {
context.font = this.font;
if (this.startLabel) {
const startLabelX = this.startX - 15;
const startLabelY = this.startY + 5;
context.fillText(this.startLabel, startLabelX, startLabelY);
}
if (this.endLabel) {
const endLabelX = this.endX - 15;
const endLabelY = this.endY - 5;
context.fillText(this.endLabel, endLabelX, endLabelY);
}
}
context.restore();
};
return Arc;
})();
// Lớp Pulse để vẽ vòng tròn lan tỏa tại điểm kết thúc
const Pulse = (function () {
function Pulse(options) {
this.x = options.x;
this.y = options.y;
this.maxRadius = options.radius;
this.color = options.color;
this.lineWidth = options.borderWidth;
this.currentRadius = 0;
}
Pulse.prototype.draw = function (context) {
const expansionRate = 0.5; // Tốc độ lan tỏa
this.currentRadius += expansionRate;
context.save();
context.translate(this.x, this.y);
let strokeColor = this.color;
const opacity = 1 - this.currentRadius / this.maxRadius;
strokeColor = utils.calculateColor(strokeColor, opacity > 0 ? opacity : 0);
context.strokeStyle = strokeColor;
context.shadowColor = strokeColor;
context.lineWidth = this.lineWidth;
context.beginPath();
context.arc(0, 0, this.currentRadius, 0, Math.PI * 2, false);
context.stroke();
context.restore();
// Reset bán kính khi đạt đến tối đa để tạo hiệu ứng lặp lại
if (Math.abs(this.maxRadius - this.currentRadius) < 0.8) {
this.currentRadius = 0;
}
}
return Pulse;
})();
// Lớp Spark để vẽ vệt sao băng
const Spark = (function () {
function Spark(options) {
const startX = options.startX, startY = options.startY, endX = options.endX, endY = options.endY;
// Tính toán tâm và bán kính của cung tròn tương tự lớp Arc
const dx = endX - startX;
const dy = endY - startY;
const dist = Math.sqrt(dx * dx + dy * dy);
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2;
const curvatureFactor = 1.5;
const centerX = midX - dy * curvatureFactor * dist / 100;
const centerY = midY + dx * curvatureFactor * dist / 100;
const radius = Math.sqrt(Math.pow(dist / 2, 2) + Math.pow(dist * curvatureFactor / 10, 2));
const startAngle = Math.atan2(startY - centerY, startX - centerX);
const endAngle = Math.atan2(endY - centerY, endX - centerX);
// Đảm bảo góc của Spark không vượt quá PI
if (startAngle * endAngle < 0) {
if (startAngle < 0) {
startAngle += Math.PI * 2;
endAngle += Math.PI * 2;
} else {
endAngle += Math.PI * 2;
}
}
this.showArrow = options.beArrow;
this.arrowSize = options.arrowSize || 3;
this.tailPointsCount = 20; // Số lượng điểm tạo hiệu ứng đuôi
this.centerX = centerX;
this.centerY = centerY;
this.startAngle = startAngle;
this.endAngle = endAngle;
this.radius = radius;
this.lineWidth = options.width || 0;
this.starWidth = options.starWidth;
this.strokeStyle = options.color || '#fff';
this.angleVelocity = (80 / Math.min(this.radius, 10000)) / this.tailPointsCount; // Tốc độ di chuyển góc
this.currentTrailAngle = this.startAngle; // Góc hiện tại của đuôi
this.animationActive = true;
this.arrowMarker = new Marker({
x: 0, // Tọa độ sẽ được cập nhật trong hàm draw
y: 0,
rotation: 0,
style: 'arrow',
color: this.strokeStyle,
size: this.arrowSize,
borderWidth: 5,
borderColor: this.strokeStyle
});
}
Spark.prototype.drawArcSegment = function (context, strokeColor, lineWidth, startAngle, endAngle) {
context.save();
context.lineWidth = lineWidth;
context.strokeStyle = strokeColor;
context.shadowColor = this.strokeStyle;
context.lineCap = "round";
context.beginPath();
context.arc(this.centerX, this.centerY, this.radius, startAngle, endAngle, false);
context.stroke();
context.restore();
};
Spark.prototype.draw = function (context) {
const endAngle = this.endAngle;
// Cập nhật góc di chuyển của vệt sao băng
const angleStep = this.angleVelocity;
let currentAngle = this.currentTrailAngle + angleStep;
this.currentTrailAngle = currentAngle;
// Vẽ các đoạn đuôi với độ mờ giảm dần
for (let i = 0; i < this.tailPointsCount; i++) {
const tailOpacity = 0.3 - 0.3 / this.tailPointsCount * i;
const tailColor = utils.calculateColor(this.strokeStyle, tailOpacity);
const tailLineWidth = 5;
const segmentStartAngle = this.currentTrailAngle - this.angleVelocity * i;
if (segmentStartAngle > this.startAngle) {
this.drawArcSegment(context, tailColor,
this.starWidth,
segmentStartAngle,
this.currentTrailAngle
);
}
}
// Vẽ mũi tên dẫn đường nếu được cấu hình
context.save();
context.translate(this.centerX, this.centerY);
if (this.showArrow) {
this.arrowMarker.x = Math.cos(this.currentTrailAngle) * this.radius;
this.arrowMarker.y = Math.sin(this.currentTrailAngle) * this.radius;
this.arrowMarker.rotation = this.currentTrailAngle + Math.PI / 2;
this.arrowMarker.draw(context);
}
context.restore();
// Dừng hoạt ảnh khi sao băng đi hết quỹ đạo
if ((endAngle - this.currentTrailAngle) * 180 / Math.PI < 0.5) {
this.currentTrailAngle = this.startAngle;
this.animationActive = false;
}
};
return Spark;
})();
// Lớp chính MigrationLayer để quản lý tất cả các đối tượng và hoạt ảnh
const Migration = (function () {
function Migration(options) {
this.data = options.data;
this.store = { // Lưu trữ các đối tượng đồ họa
arcs: [],
markers: [],
pulses: [],
sparks: []
};
this.isAnimating = true;
this.hasStarted = false;
this.context = options.context;
this.styleConfig = options.style;
this.init();
}
Migration.prototype.init = function () {
this.updateData(this.data);
};
Migration.prototype.clear = function () {
this.store = {
arcs: [],
markers: [],
pulses: [],
sparks: []
};
this.isAnimating = true;
this.hasStarted = false;
window.cancelAnimationFrame(this.animationFrameId); // Hủy bỏ hoạt ảnh đang chạy
};
Migration.prototype.updateData = function (newData) {
if (!newData || newData.length === 0) {
return;
}
this.clear(); // Xóa dữ liệu cũ trước khi cập nhật
this.data = newData;
if (this.data && this.data.length > 0) {
arrayUtils.forEach(this.data, function (element) {
// Tạo đối tượng Arc
const arc = new Arc({
startX: element.from[0],
startY: element.from[1],
endX: element.to[0],
endY: element.to[1],
labels: element.labels,
label: this.styleConfig.arc.label,
font: this.styleConfig.arc.font,
width: this.styleConfig.arc.width,
arcAlpha: this.styleConfig.arc.arcAlpha,
color: this.styleConfig.arc.arcStrokeColor,
});
// Tạo đối tượng Marker cho điểm kết thúc (nếu có mũi tên)
const marker = new Marker({
x: element.to[0],
y: element.to[1],
rotation: arc.endAngle + Math.PI / 2,
style: 'arrow',
color: this.styleConfig.arc.arcStrokeColor,
size: 6,
borderWidth: 0,
borderColor: this.styleConfig.arc.arcStrokeColor,
});
// Tạo đối tượng Pulse cho điểm kết thúc (nếu có hiệu ứng lan tỏa)
const pulse = new Pulse({
x: element.to[0],
y: element.to[1],
radius: this.styleConfig.pulse.radius,
color: this.styleConfig.pulse.color || element.color,
borderWidth: this.styleConfig.pulse.borderWidth
});
// Tạo đối tượng Spark cho vệt sao băng
const spark = new Spark({
startX: element.from[0],
startY: element.from[1],
endX: element.to[0],
endY: element.to[1],
width: 5,
starWidth: this.styleConfig.star.width,
color: element.color,
beArrow: element.beArrow,
arrowSize: element.arrowSize,
});
this.store.arcs.push(arc);
element.beArrow && this.store.markers.push(marker);
element.bePulse && this.store.pulses.push(pulse);
this.store.sparks.push(spark);
}, this);
}
};
Migration.prototype.start = function (canvas) {
const that = this;
if (!this.hasStarted) {
(function drawFrame() {
that.animationFrameId = window.requestAnimationFrame(drawFrame, canvas);
if (that.isAnimating) {
// Làm mới canvas để xóa khung hình trước đó
canvas.width = canvas.width; // Hoặc canvas.width += 1; canvas.width -= 1;
// Vẽ tất cả các đối tượng đồ họa
for (const layerType in that.store) {
const shapes = that.store[layerType];
for (let i = 0, len = shapes.length; i < len; i++) {
shapes[i].draw(that.context);
}
}
}
})();
this.hasStarted = true;
}
};
Migration.prototype.play = function () {
this.isAnimating = true;
};
Migration.prototype.pause = function () {
this.isAnimating = false;
};
return Migration;
})();
// Định nghĩa lớp L.MigrationLayer kế thừa từ L.Class
L.MigrationLayer = L.Class.extend({
options: {
map: {},
data: {},
pulseRadius: 25,
pulseBorderWidth: 3,
arcWidth: 1,
arcLabel: true,
arcAlpha: 1,
arcLabelFont: '15px sans-serif',
starWidth: 4,
arcStrokeColor: '#f00f00',
},
_setOptions: function (obj, options) {
if (!obj.options) {
obj.options = {};
}
for (const i in options) {
obj.options[i] = options[i];
}
return obj.options;
},
initialize: function (options) {
this._setOptions(this, options);
this._map = this.options.map || {};
this._data = this.options.data || [];
// Cấu hình kiểu dáng cho các đối tượng đồ họa
this._styleConfig = {
pulse: {
radius: this.options.pulseRadius,
borderWidth: this.options.pulseBorderWidth,
color: this.options.pulseColor,
},
arc: {
width: this.options.arcWidth,
label: this.options.arcLabel,
font: this.options.arcLabelFont,
arcAlpha: this.options.arcAlpha,
arcStrokeColor: this.options.arcStrokeColor,
},
star: {
width: this.options.starWidth,
},
};
this._isVisible = true;
this._init();
},
_init: function () {
// Tạo container cho canvas
const container = L.DomUtil.create('div', 'leaflet-MigrationLayer-container');
container.style.position = 'absolute';
container.style.width = this._map.getSize().x + "px";
container.style.height = this._map.getSize().y + "px";
this.container = container;
// Tạo canvas và lấy context 2D
this.canvas = document.createElement('canvas');
this.context = this.canvas.getContext('2d');
container.appendChild(this.canvas);
this._map.getPanes().overlayPane.appendChild(container); // Thêm container vào DOM của Leaflet
// Khởi tạo lớp Migration nếu chưa có
if (!this.migrationInstance) {
const convertedData = this._convertDataToPixels(this._data);
this.migrationInstance = new Migration({
data: convertedData,
context: this.context,
style: this._styleConfig
});
}
},
_resizeCanvas: function () {
// Điều chỉnh kích thước canvas theo kích thước của bản đồ
const mapContainer = this._map.getContainer();
this.canvas.setAttribute('width', mapContainer.clientWidth);
this.canvas.setAttribute('height', mapContainer.clientHeight);
},
_convertDataToPixels: function (geoData) {
// Chuyển đổi dữ liệu địa lý (lat/lng) sang tọa độ pixel trên canvas
const bounds = this._map.getBounds();
if (!geoData || !bounds) {
return [];
}
return arrayUtils.map(geoData, (d) => {
const fromPixel = this._map.latLngToContainerPoint(new L.LatLng(d.from[1], d.from[0]));
const toPixel = this._map.latLngToContainerPoint(new L.LatLng(d.to[1], d.to[0]));
return {
from: [fromPixel.x, fromPixel.y],
to: [toPixel.x, toPixel.y],
labels: d.labels,
value: d.value,
color: d.color,
beArrow: d.beArrow,
arrowSize: d.arrowSize,
bePulse: d.bePulse,
};
}, this);
},
_bindMapEvents: function () {
// Gắn kết các sự kiện của bản đồ Leaflet
const that = this;
this._map.on('moveend', function () {
that.migrationInstance.play();
that._redraw();
});
this._map.on('zoomstart', function () {
that.container.style.display = 'none'; // Ẩn lớp khi zoom để tránh giật
});
this._map.on('zoomend', function () {
if (that._isVisible) {
that.container.style.display = '';
that._redraw();
}
});
},
_redraw: function () {
// Vẽ lại lớp dựa trên trạng thái hiện tại của bản đồ
this._resizeCanvas();
this._updateMapPosition();
const pixelData = this._convertDataToPixels(this._data);
this.migrationInstance.updateData(pixelData);
this.migrationInstance.start(this.canvas);
},
_updateMapPosition: function () {
// Cập nhật vị trí của container canvas theo bản đồ
const bounds = this._map.getBounds();
const topLeft = this._map.latLngToLayerPoint(bounds.getNorthWest());
L.DomUtil.setPosition(this.container, topLeft);
},
addTo: function () {
// Phương thức để thêm lớp vào bản đồ
this._bindMapEvents();
this._resizeCanvas();
this._updateMapPosition();
const pixelData = this._convertDataToPixels(this._data);
this.migrationInstance.updateData(pixelData);
this.migrationInstance.start(this.canvas);
return this;
},
onClick: function (callback) {
// Hàm xử lý sự kiện click trên canvas
const isPointInArcStroke = (x, y, arc) => {
this.context.beginPath();
// Tăng lineWidth tạm thời để dễ dàng click vào đường cong
const originalLineWidth = this.context.lineWidth;
this.context.lineWidth = (arc.lineWidth > 6) ? arc.lineWidth : 12;
this.context.arc(arc.centerX, arc.centerY, arc.radius, arc.startAngle, arc.endAngle, false);
const isPointIn = this.context.isPointInStroke(x, y);
this.context.lineWidth = originalLineWidth; // Khôi phục lineWidth ban đầu
return isPointIn;
}
const that = this;
this.canvas.addEventListener('click', function (event) {
const rect = that.canvas.getBoundingClientRect();
const clickX = event.clientX - rect.left;
const clickY = event.clientY - rect.top;
// Duyệt qua các cung tròn để kiểm tra xem điểm click có nằm trên cung nào không
that.migrationInstance.store.arcs.forEach((arc, index) => {
if (isPointInArcStroke(clickX, clickY, arc)) {
callback(arc); // Gọi hàm callback với dữ liệu của cung tròn được click
}
});
});
},
setData: function (newData) {
// Cập nhật dữ liệu và vẽ lại lớp
this._data = newData;
this._redraw();
},
hide: function () {
// Ẩn lớp
this.container.style.display = 'none';
this._isVisible = false;
},
show: function () {
// Hiển thị lớp
this.container.style.display = '';
this._isVisible = true;
},
play: function () {
// Tiếp tục hoạt ảnh
this.migrationInstance.play();
},
pause: function () {
// Tạm dừng hoạt ảnh
this.migrationInstance.pause();
},
destroy: function () {
// Xóa lớp và các tài nguyên liên quan
this.migrationInstance.clear();
// Xóa container khỏi DOM
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
// Xóa các sự kiện đã đăng ký với bản đồ
this._map.off('moveend');
this._map.off('zoomstart');
this._map.off('zoomend');
}
});
// Hàm tạo ngắn gọn để khởi tạo L.MigrationLayer
L.migrationLayer = function (options) {
return new L.MigrationLayer(options);
};
})(window);