(function (L, d3, satelliteJs) { var RADIANS = Math.PI / 180; var DEGREES = 180 / Math.PI; var R_EARTH = 6378.137; // equatorial radius (km) /* =============================================== */ /* =============== CLOCK ========================= */ /* =============================================== */ /** * Factory function for keeping track of elapsed time and rates. */ function Clock() { this._rate = 60; // 1ms elapsed : 60sec simulated this._date = d3.now(); this._elapsed = 0; }; Clock.prototype.date = function (timeInMs) { if (!arguments.length) return this._date + (this._elapsed * this._rate); this._date = timeInMs; return this; }; Clock.prototype.elapsed = function (ms) { if (!arguments.length) return this._date - d3.now(); // calculates elapsed this._elapsed = ms; return this; }; Clock.prototype.rate = function (secondsPerMsElapsed) { if (!arguments.length) return this._rate; this._rate = secondsPerMsElapsed; return this; }; /* ==================================================== */ /* =============== CONVERSION ========================= */ /* ==================================================== */ function satrecToFeature(satrec, date, props) { var properties = props || {}; var positionAndVelocity = satelliteJs.propagate(satrec, date); var gmst = satelliteJs.gstime(date); var positionGd = satelliteJs.eciToGeodetic(positionAndVelocity.position, gmst); properties.height = positionGd.height; return { type: 'Feature', properties: properties, geometry: { type: 'Point', coordinates: [ positionGd.longitude * DEGREES, positionGd.latitude * DEGREES ] } }; }; /* ==================================================== */ /* =============== TLE ================================ */ /* ==================================================== */ /** * Factory function for working with TLE. */ function TLE() { this._properties; this._date; }; TLE.prototype._lines = function (arry) { return arry.slice(0, 2); }; TLE.prototype.satrecs = function (tles) { return tles.map(function (d) { return satelliteJs.twoline2satrec.apply(null, this._lines(d)); }); }; TLE.prototype.features = function (tles) { var date = this._date || d3.now(); return tles.map(function (d) { var satrec = satelliteJs.twoline2satrec.apply(null, this._lines(d)); return satrecToFeature(satrec, date, this._properties(d)); }); }; TLE.prototype.lines = function (func) { if (!arguments.length) return this._lines; this._lines = func; return this; }; TLE.prototype.properties = function (func) { if (!arguments.length) return this._properties; this._properties = func; return this; }; TLE.prototype.date = function (ms) { if (!arguments.length) return this._date; this._date = ms; return this; }; /* ==================================================== */ /* =============== PARSE ============================== */ /* ==================================================== */ /** * Parses text file string of tle into groups. * @return {string[][]} Like [['tle line 1', 'tle line 2'], ...] */ function parseTle(tleString) { // remove last newline so that we can properly split all the lines var lines = tleString.replace(/\r?\n$/g, '').split(/\r?\n/); return lines.reduce(function (acc, cur, index) { if (index % 2 === 0) acc.push([]); acc[acc.length - 1].push(cur); return acc; }, []); }; /* ==================================================== */ /* =============== SATELLITE ========================== */ /* ==================================================== */ /** * Satellite factory function that wraps satellitejs functionality * and can compute footprints based on TLE and date * * @param {string[][]} tle two-line element * @param {Date} date date to propagate with TLE */ function Satellite(tle, date) { this._satrec = satelliteJs.twoline2satrec(tle[0], tle[1]); this._satNum = this._satrec.satnum; // NORAD Catalog Number this._altitude; // km this._position = { lat: null, lng: null }; this._halfAngle; // degrees this._date; this._gmst; this.setDate(date); this.update(); this._orbitType = this.orbitTypeFromAlt(this._altitude); // LEO, MEO, or GEO }; /** * Updates satellite position and altitude based on current TLE and date */ Satellite.prototype.update = function () { var positionAndVelocity = satelliteJs.propagate(this._satrec, this._date); var positionGd = satelliteJs.eciToGeodetic(positionAndVelocity.position, this._gmst); this._position = { lat: positionGd.latitude * DEGREES, lng: positionGd.longitude * DEGREES }; this._altitude = positionGd.height; return this; }; /** * @returns {GeoJSON.Polygon} GeoJSON describing the satellite's current footprint on the Earth */ Satellite.prototype.getFootprint = function () { var theta = this._halfAngle * RADIANS; coreAngle = this._coreAngle(theta, this._altitude, R_EARTH) * DEGREES; return d3.geoCircle() .center([this._position.lng, this._position.lat]) .radius(coreAngle)(); }; /** * A conical satellite with half angle casts a circle on the Earth. Find the angle * from the center of the earth to the radius of this circle * @param {number} theta: Satellite half angle in radians * @param {number} altitude Satellite altitude * @param {number} r Earth radius * @returns {number} core angle in radians */ Satellite.prototype._coreAngle = function (theta, altitude, r) { // if FOV is larger than Earth, assume it goes to the tangential point if (Math.sin(theta) > r / (altitude + r)) { return Math.acos(r / (r + altitude)); } return Math.abs(Math.asin((r + altitude) * Math.sin(theta) / r)) - theta; }; Satellite.prototype.halfAngle = function (halfAngle) { if (!arguments.length) return this._halfAngle; this._halfAngle = halfAngle; return this; }; Satellite.prototype.satNum = function (satNum) { if (!arguments.length) return this._satNum; this._satNum = satNum; return this; }; Satellite.prototype.altitude = function (altitude) { if (!arguments.length) return this._altitude; this._altitude = altitude; return this; }; Satellite.prototype.position = function (position) { if (!arguments.length) return this._position; this._position = position; return this; }; Satellite.prototype.getOrbitType = function () { return this._orbitType; }; /** * sets both the date and the Greenwich Mean Sidereal Time * @param {Date} date */ Satellite.prototype.setDate = function (date) { this._date = date; this._gmst = satelliteJs.gstime(date); return this; }; /** * Maps an altitude to a type of satellite * @param {number} altitude (in KM) * @returns {'LEO' | 'MEO' | 'GEO'} */ Satellite.prototype.orbitTypeFromAlt = function (altitude) { this._altitude = altitude || this._altitude; return this._altitude < 1200 ? 'LEO' : this._altitude > 22000 ? 'GEO' : 'MEO'; }; /* =============================================== */ /* =================== GLOBE ===================== */ /* =============================================== */ // Approximate date the tle data was aquired from https://www.space-track.org/#recent var TLE_DATA_DATE = new Date(2018, 0, 26).getTime(); var activeClock; var sats; var svg = d3.select('#globe'); var marginTop = 20; var width = svg.attr('width'); var height = svg.attr('height') - marginTop; var projection = d3.geoOrthographic() .scale((height - 10) / 2) .translate([width / 2, height / 2 + marginTop]) .precision(0.1); var geoPath = d3.geoPath() .projection(projection); svg.append('path') .datum({ type: 'Sphere' }) .style('cursor', 'grab') .attr('fill', '#2E86AB') .attr('d', geoPath); function initGlobe() { d3.json('world-110m.json').then(function (worldData) { svg.selectAll('.segment') .data(topojson.feature(worldData, worldData.objects.countries).features) .enter().append('path') .style('cursor', 'grab') .attr('class', 'segment') .attr('d', geoPath) .style('stroke', '#888') .style('stroke-width', '1px') .style('fill', '#e5e5e5') .style('opacity', '.4'); }); } function updateSats(date) { sats.forEach(function (sat) { return sat.setDate(date).update() }); return sats }; /** * Create satellite objects for each record in the TLEs and begin animation * @param {string[][]} parsedTles */ function initSats(parsedTles) { activeClock = new Clock() .rate(100) .date(TLE_DATA_DATE); sats = parsedTles.map(function (tle) { var sat = new Satellite(tle, new Date(2018, 0, 26)); //61.73 degree half angle corresponds to 3 degree coverage angle, obviously //this is far less than we intend to provide but it works well for this graphic. sat.halfAngle(61.73); return sat; }); window.requestAnimationFrame(animateSats); return sats; }; function draw() { redrawGlobe(); svg.selectAll('.footprint') .data(sats, function (sat) { return sat.satNum(); }) .join( function (enter) { return enter.append('path') .attr('class', function (sat) { return 'footprint footprint--' + sat.getOrbitType() }) .style('cursor', 'grab'); } ).attr('d', function (sat) { return geoPath(sat.getFootprint()); }); }; function redrawGlobe() { svg.selectAll('.segment') .attr('d', geoPath); } var m0; var o0; function mousedown() { m0 = [d3.event.pageX, d3.event.pageY]; o0 = projection.rotate(); d3.event.preventDefault(); }; function mousemove() { if (m0) { var m1 = [d3.event.pageX, d3.event.pageY]; const o1 = [o0[0] + (m1[0] - m0[0]) / 6, o0[1] + (m0[1] - m1[1]) / 6]; projection.rotate(o1); draw(); } }; function mouseup() { if (m0) { mousemove(); m0 = null; } } svg.on('mousedown', mousedown); d3.select(window) .on('mousemove', mousemove) .on('mouseup', mouseup); function animateSats(elapsed) { var dateInMs = activeClock.elapsed(elapsed) .date(); var date = new Date(dateInMs); svg.select('.date-counter').text('' + date); updateSats(date); draw(); window.requestAnimationFrame(animateSats); } initGlobe(); // WARNING: THE TLES HERE ARE NOT REAL NORAD-PUBLISHED TLES - THE TLE FORMAT // IS USED ONLY AS A WAY TO SPECIFY THE ORBITS IN THE CONSTELLATION. d3.text('tles.txt') .then(parseTle) .then(initSats); }(window.L, window.d3, window.satellite))