Youen Toupin
3 years ago
6 changed files with 273 additions and 209 deletions
@ -0,0 +1,6 @@ |
|||||||
|
namespace HtmlUtils { |
||||||
|
export function closest (el: Element, predicate: (e: Element) => boolean) { |
||||||
|
do if (predicate(el)) return el; |
||||||
|
while (el = el && <Element>el.parentNode); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,5 @@ |
|||||||
|
namespace MathUtils { |
||||||
|
export function clamp(x: number, mini: number, maxi: number) { |
||||||
|
return x <= mini ? mini : (x >= maxi ? maxi : x); |
||||||
|
} |
||||||
|
} |
@ -1,146 +0,0 @@ |
|||||||
function clamp(x: number, mini: number, maxi: number) { |
|
||||||
return x <= mini ? mini : (x >= maxi ? maxi : x); |
|
||||||
} |
|
||||||
|
|
||||||
class Vehicle { |
|
||||||
batteryCapacity: number; |
|
||||||
batteryEfficiency: number = 1.0; // TODO: typical efficiency of a Li-ion battery (round-trip) is 90%
|
|
||||||
|
|
||||||
solarPanelEfficiency: number = 0.15; |
|
||||||
solarPanelArea: number = 1.0; // in square meters
|
|
||||||
|
|
||||||
additionalWeight: number; // additional weight, not counting cyclist and empty vehicle weight, in kg
|
|
||||||
|
|
||||||
motorConsumption(distance: number, ascendingElevation: number): number { |
|
||||||
// empirical measures
|
|
||||||
let maxWeight = 200; // in kg
|
|
||||||
let maxWeightAdditionalConsumption = 4; // in Wh/km
|
|
||||||
let maxTestedElevation = 500; // in meters
|
|
||||||
let maxTestedElevationConsumption = 7; // in Wh/m
|
|
||||||
let baseConsumption = 14; // in Wh/km
|
|
||||||
|
|
||||||
let weightRelatedConsumption = clamp(this.additionalWeight * maxWeightAdditionalConsumption / maxWeight, 0, maxWeightAdditionalConsumption); |
|
||||||
|
|
||||||
// TODO: should not be multiplied by distance
|
|
||||||
// TODO: should be multiplied by total vehicle weight
|
|
||||||
let elevationRelatedConsumption = clamp(ascendingElevation * maxTestedElevationConsumption / maxTestedElevation, 0, maxTestedElevationConsumption); |
|
||||||
|
|
||||||
return distance * (baseConsumption + weightRelatedConsumption + elevationRelatedConsumption) |
|
||||||
} |
|
||||||
|
|
||||||
solarPower(irradiance: number): number { |
|
||||||
// TODO: should decompose climate data in normal radiance (modulated by incident angle) and diffuse irradiance
|
|
||||||
// TODO: should add a shadowing factor (the panel won't be always exposed to the sun)
|
|
||||||
return irradiance * this.solarPanelArea * this.solarPanelEfficiency; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
interface Outing { |
|
||||||
distance: number; // in km
|
|
||||||
ascendingElevation: number; // in meters
|
|
||||||
} |
|
||||||
|
|
||||||
class OutingPlanning { |
|
||||||
constructor(public dailyDistance: number, public dailyAscendingElevation: number) { |
|
||||||
} |
|
||||||
|
|
||||||
getOuting(dayOfWeek: number, hourOfDay: number, outing: Outing) { |
|
||||||
let dailyRatio = 0; |
|
||||||
|
|
||||||
if(dayOfWeek >= 5) { |
|
||||||
// week end
|
|
||||||
dailyRatio = hourOfDay == 10 ? 1.0 : 0.0; |
|
||||||
} |
|
||||||
else { |
|
||||||
// other week day
|
|
||||||
dailyRatio = hourOfDay == 7 || hourOfDay == 15 ? 0.5 : 0.0; |
|
||||||
} |
|
||||||
|
|
||||||
outing.distance = dailyRatio * this.dailyDistance; |
|
||||||
outing.ascendingElevation = this.dailyAscendingElevation; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
interface SimulationResult { |
|
||||||
batteryLevel: number[]; // Remaining energy in the battery over time (one entry per hour), in Wh
|
|
||||||
gridChargeCount: number; |
|
||||||
cumulatedGridRechargeEnergy: number; // Cumulated energy added to the battery from the power grid, in Wh of battery charge (actual power grid consumption will be slightly higer due to losses)
|
|
||||||
cumulatedSolarRechargeEnergy: number; // Cumulated energy added to the battery from the solar panel, in Wh of battery charge (actual generated power is slightly higher due to losses)
|
|
||||||
cumulatedMotorConsumption: number; // Cumulated energy consumed by the motor, in Wh. In this simulation, this is equal to the energy drawn from the battery.
|
|
||||||
} |
|
||||||
|
|
||||||
interface SimulationParameters { |
|
||||||
batteryCapacity: number, |
|
||||||
additionalWeight: number, |
|
||||||
climateZone: string, |
|
||||||
dailyDistance: number, |
|
||||||
dailyAscendingElevation: number |
|
||||||
} |
|
||||||
|
|
||||||
function runSimulation(vehicle: Vehicle, solarIrradiance: number[], planning: OutingPlanning): SimulationResult { |
|
||||||
let result: SimulationResult = { |
|
||||||
batteryLevel: [], |
|
||||||
gridChargeCount: 0, |
|
||||||
cumulatedGridRechargeEnergy: 0, |
|
||||||
cumulatedSolarRechargeEnergy: 0, |
|
||||||
cumulatedMotorConsumption: 0 |
|
||||||
}; |
|
||||||
|
|
||||||
let remainingBatteryCharge = vehicle.batteryCapacity; |
|
||||||
|
|
||||||
let outing: Outing = { distance: 0, ascendingElevation: 0 }; |
|
||||||
|
|
||||||
for(let day = 0; day < 365; ++day) { |
|
||||||
for(let hour = 0; hour < 24; ++hour) { |
|
||||||
let hourIdx = day * 24 + hour; |
|
||||||
|
|
||||||
planning.getOuting(day % 7, hour, outing); |
|
||||||
|
|
||||||
let consumption = vehicle.motorConsumption(outing.distance, outing.ascendingElevation); |
|
||||||
let production = vehicle.solarPower(solarIrradiance[hourIdx]) * 1.0; // produced energy in Wh is equal to power (W) multiplied by time (h)
|
|
||||||
|
|
||||||
let solarCharge = production * vehicle.batteryEfficiency; |
|
||||||
|
|
||||||
// TODO: we should keep a margin because real users will recharge before they reach the bare minimum required for an outing
|
|
||||||
remainingBatteryCharge += solarCharge - consumption; |
|
||||||
if(remainingBatteryCharge > vehicle.batteryCapacity) { |
|
||||||
solarCharge -= remainingBatteryCharge - vehicle.batteryCapacity; |
|
||||||
remainingBatteryCharge = vehicle.batteryCapacity;
|
|
||||||
} |
|
||||||
else if(remainingBatteryCharge <= 0) { |
|
||||||
let rechargeEnergy = vehicle.batteryCapacity - remainingBatteryCharge; |
|
||||||
remainingBatteryCharge += rechargeEnergy; |
|
||||||
result.cumulatedGridRechargeEnergy += rechargeEnergy; |
|
||||||
result.gridChargeCount += 1; |
|
||||||
} |
|
||||||
|
|
||||||
result.cumulatedMotorConsumption += consumption; |
|
||||||
result.cumulatedSolarRechargeEnergy += solarCharge; |
|
||||||
|
|
||||||
result.batteryLevel[hourIdx] = remainingBatteryCharge; |
|
||||||
}
|
|
||||||
} |
|
||||||
|
|
||||||
return result; |
|
||||||
} |
|
||||||
|
|
||||||
function startSimulation(parameters: SimulationParameters): SimulationResult { |
|
||||||
let climateData = (<any>window)['climate-zones-data.csv']; |
|
||||||
|
|
||||||
let vehicle = new Vehicle(); |
|
||||||
vehicle.batteryCapacity = parameters.batteryCapacity; |
|
||||||
vehicle.additionalWeight = parameters.additionalWeight; |
|
||||||
let solarIrradiance: number[] = climateData[parameters.climateZone.toLowerCase()]; |
|
||||||
let planning = new OutingPlanning(parameters.dailyDistance, parameters.dailyAscendingElevation); |
|
||||||
|
|
||||||
let simulationResult = runSimulation(vehicle, solarIrradiance, planning); |
|
||||||
//console.log(solarIrradiance);
|
|
||||||
console.log(simulationResult); |
|
||||||
|
|
||||||
let averageKwhCost = 0.192; // in €/kWh TODO: to verify, this price seems too high
|
|
||||||
console.log('Grid recharge cost: ' + (Math.round(simulationResult.gridChargeCount*(vehicle.batteryCapacity/1000)*averageKwhCost*100)/100) + '€'); |
|
||||||
|
|
||||||
console.log('Solar energy ratio: ' + Math.round(100*(simulationResult.cumulatedMotorConsumption-(simulationResult.gridChargeCount+1)*vehicle.batteryCapacity)/simulationResult.cumulatedMotorConsumption) + '%'); |
|
||||||
|
|
||||||
return simulationResult; |
|
||||||
} |
|
@ -0,0 +1,82 @@ |
|||||||
|
|
||||||
|
|
||||||
|
interface SimulationParameters { |
||||||
|
batteryCapacity: number, |
||||||
|
additionalWeight: number, |
||||||
|
climateZone: string, |
||||||
|
dailyDistance: number, |
||||||
|
dailyAscendingElevation: number |
||||||
|
} |
||||||
|
|
||||||
|
function runSimulation(parameters: SimulationParameters): Simulator.SimulationResult { |
||||||
|
let climateData = (<any>window)['climate-zones-data.csv']; |
||||||
|
|
||||||
|
let vehicle = new Simulator.Vehicle(); |
||||||
|
vehicle.batteryCapacity = parameters.batteryCapacity; |
||||||
|
vehicle.additionalWeight = parameters.additionalWeight; |
||||||
|
let solarIrradiance: number[] = climateData[parameters.climateZone.toLowerCase()]; |
||||||
|
let planning = new Simulator.OutingPlanning(parameters.dailyDistance, parameters.dailyAscendingElevation); |
||||||
|
|
||||||
|
let simulationResult = Simulator.simulate(vehicle, solarIrradiance, planning); |
||||||
|
//console.log(solarIrradiance);
|
||||||
|
console.log(simulationResult); |
||||||
|
|
||||||
|
let averageKwhCost = 0.192; // in €/kWh TODO: to verify, this price seems too high
|
||||||
|
console.log('Grid recharge cost: ' + (Math.round(simulationResult.gridChargeCount*(vehicle.batteryCapacity/1000)*averageKwhCost*100)/100) + '€'); |
||||||
|
|
||||||
|
console.log('Solar energy ratio: ' + Math.round(100*(simulationResult.cumulatedMotorConsumption-(simulationResult.gridChargeCount+1)*vehicle.batteryCapacity)/simulationResult.cumulatedMotorConsumption) + '%'); |
||||||
|
|
||||||
|
return simulationResult; |
||||||
|
} |
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() { |
||||||
|
let container = document.getElementById('simulator'); |
||||||
|
|
||||||
|
// Insert HTML code in the container
|
||||||
|
container.innerHTML += (<any>window)['simulator.html']; |
||||||
|
|
||||||
|
// In order to be able to style SVG elements with CSS, and register events with javascript, we must use inline SVG (we can't use an img tag)
|
||||||
|
// For this purpose, the SVG file contents are embedded in a javascript file
|
||||||
|
container.querySelector('#zones-map').innerHTML = (<any>window)['climate-zones-map.svg']; |
||||||
|
|
||||||
|
container.querySelectorAll("[data-activate-modal]").forEach(elt => { |
||||||
|
elt.addEventListener('click', e => { |
||||||
|
container.querySelector('#'+elt.getAttribute('data-activate-modal')).classList.toggle('is-active', true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
container.querySelectorAll('.modal-close, .modal-card-head .delete').forEach(elt => { |
||||||
|
elt.addEventListener('click', e => { |
||||||
|
HtmlUtils.closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
let zoneSelector = <HTMLSelectElement>container.querySelector('#zone-selector'); |
||||||
|
container.querySelectorAll('.climate-zone').forEach(elt => { |
||||||
|
elt.addEventListener('click', e => { |
||||||
|
let zoneName = elt.getAttribute('id'); |
||||||
|
zoneSelector.value = zoneName; |
||||||
|
HtmlUtils.closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
container.querySelector('#simulate-button').addEventListener('click', e => { |
||||||
|
let parameters: SimulationParameters = { |
||||||
|
batteryCapacity: Number((<HTMLInputElement>container.querySelector('[name=battery-capacity]')).value), |
||||||
|
additionalWeight: Number((<HTMLInputElement>container.querySelector('[name=additional-weight]')).value), |
||||||
|
climateZone: (<HTMLSelectElement>container.querySelector('#zone-selector')).value, |
||||||
|
dailyDistance: Number((<HTMLInputElement>container.querySelector('[name=daily-distance]')).value), |
||||||
|
dailyAscendingElevation: Number((<HTMLInputElement>container.querySelector('[name=daily-elevation]')).value), |
||||||
|
}; |
||||||
|
|
||||||
|
let simulationResult = runSimulation(parameters); |
||||||
|
|
||||||
|
let resultsContainer = container.querySelector('.simulation-results'); |
||||||
|
|
||||||
|
let batteryChargeGraph = new SvgDrawing.SvgElement(resultsContainer.querySelector('.battery-charge-graph svg')); |
||||||
|
batteryChargeGraph.viewport.logical = { x: 0, y: 0, width: 365*24, height: parameters.batteryCapacity } |
||||||
|
batteryChargeGraph.graph(simulationResult.batteryLevel); |
||||||
|
|
||||||
|
resultsContainer.classList.toggle('is-hidden', false); |
||||||
|
}); |
||||||
|
}); |
@ -1,75 +1,115 @@ |
|||||||
function closest (el: Element, predicate: (e: Element) => boolean) { |
namespace Simulator { |
||||||
do if (predicate(el)) return el; |
export class Vehicle { |
||||||
while (el = el && <Element>el.parentNode); |
batteryCapacity: number; |
||||||
} |
batteryEfficiency: number = 1.0; // TODO: typical efficiency of a Li-ion battery (round-trip) is 90%
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() { |
solarPanelEfficiency: number = 0.15; |
||||||
let container = document.getElementById('simulator'); |
solarPanelArea: number = 1.0; // in square meters
|
||||||
|
|
||||||
// Insert HTML code in the container
|
additionalWeight: number; // additional weight, not counting cyclist and empty vehicle weight, in kg
|
||||||
container.innerHTML += (<any>window)['simulator.html']; |
|
||||||
|
motorConsumption(distance: number, ascendingElevation: number): number { |
||||||
// In order to be able to style SVG elements with CSS, and register events with javascript, we must use inline SVG (we can't use an img tag)
|
// empirical measures
|
||||||
// For this purpose, the SVG file contents are embedded in a javascript file
|
let maxWeight = 200; // in kg
|
||||||
container.querySelector('#zones-map').innerHTML = (<any>window)['climate-zones-map.svg']; |
let maxWeightAdditionalConsumption = 4; // in Wh/km
|
||||||
|
let maxTestedElevation = 500; // in meters
|
||||||
|
let maxTestedElevationConsumption = 7; // in Wh/m
|
||||||
|
let baseConsumption = 14; // in Wh/km
|
||||||
|
|
||||||
|
let weightRelatedConsumption = MathUtils.clamp(this.additionalWeight * maxWeightAdditionalConsumption / maxWeight, 0, maxWeightAdditionalConsumption); |
||||||
|
|
||||||
|
// TODO: should not be multiplied by distance
|
||||||
|
// TODO: should be multiplied by total vehicle weight
|
||||||
|
let elevationRelatedConsumption = MathUtils.clamp(ascendingElevation * maxTestedElevationConsumption / maxTestedElevation, 0, maxTestedElevationConsumption); |
||||||
|
|
||||||
|
return distance * (baseConsumption + weightRelatedConsumption + elevationRelatedConsumption) |
||||||
|
} |
||||||
|
|
||||||
|
solarPower(irradiance: number): number { |
||||||
|
// TODO: should decompose climate data in normal radiance (modulated by incident angle) and diffuse irradiance
|
||||||
|
// TODO: should add a shadowing factor (the panel won't be always exposed to the sun)
|
||||||
|
return irradiance * this.solarPanelArea * this.solarPanelEfficiency; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
container.querySelectorAll("[data-activate-modal]").forEach(elt => { |
export interface Outing { |
||||||
elt.addEventListener('click', e => { |
distance: number; // in km
|
||||||
container.querySelector('#'+elt.getAttribute('data-activate-modal')).classList.toggle('is-active', true); |
ascendingElevation: number; // in meters
|
||||||
}); |
} |
||||||
}); |
|
||||||
|
|
||||||
container.querySelectorAll('.modal-close, .modal-card-head .delete').forEach(elt => { |
export class OutingPlanning { |
||||||
elt.addEventListener('click', e => { |
constructor(public dailyDistance: number, public dailyAscendingElevation: number) { |
||||||
closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false); |
} |
||||||
}); |
|
||||||
}); |
getOuting(dayOfWeek: number, hourOfDay: number, outing: Outing) { |
||||||
|
let dailyRatio = 0; |
||||||
|
|
||||||
|
if(dayOfWeek >= 5) { |
||||||
|
// week end
|
||||||
|
dailyRatio = hourOfDay == 10 ? 1.0 : 0.0; |
||||||
|
} |
||||||
|
else { |
||||||
|
// other week day
|
||||||
|
dailyRatio = hourOfDay == 7 || hourOfDay == 15 ? 0.5 : 0.0; |
||||||
|
} |
||||||
|
|
||||||
|
outing.distance = dailyRatio * this.dailyDistance; |
||||||
|
outing.ascendingElevation = this.dailyAscendingElevation; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
let zoneSelector = <HTMLSelectElement>container.querySelector('#zone-selector'); |
export interface SimulationResult { |
||||||
container.querySelectorAll('.climate-zone').forEach(elt => { |
batteryLevel: number[]; // Remaining energy in the battery over time (one entry per hour), in Wh
|
||||||
elt.addEventListener('click', e => { |
gridChargeCount: number; |
||||||
let zoneName = elt.getAttribute('id'); |
cumulatedGridRechargeEnergy: number; // Cumulated energy added to the battery from the power grid, in Wh of battery charge (actual power grid consumption will be slightly higer due to losses)
|
||||||
zoneSelector.value = zoneName; |
cumulatedSolarRechargeEnergy: number; // Cumulated energy added to the battery from the solar panel, in Wh of battery charge (actual generated power is slightly higher due to losses)
|
||||||
closest(elt, e => e.classList.contains('modal')).classList.toggle('is-active', false); |
cumulatedMotorConsumption: number; // Cumulated energy consumed by the motor, in Wh. In this simulation, this is equal to the energy drawn from the battery.
|
||||||
}); |
} |
||||||
}); |
|
||||||
|
|
||||||
container.querySelector('#simulate-button').addEventListener('click', e => { |
export function simulate(vehicle: Vehicle, solarIrradiance: number[], planning: OutingPlanning): SimulationResult { |
||||||
let parameters: SimulationParameters = { |
let result: SimulationResult = { |
||||||
batteryCapacity: Number((<HTMLInputElement>container.querySelector('[name=battery-capacity]')).value), |
batteryLevel: [], |
||||||
additionalWeight: Number((<HTMLInputElement>container.querySelector('[name=additional-weight]')).value), |
gridChargeCount: 0, |
||||||
climateZone: (<HTMLSelectElement>container.querySelector('#zone-selector')).value, |
cumulatedGridRechargeEnergy: 0, |
||||||
dailyDistance: Number((<HTMLInputElement>container.querySelector('[name=daily-distance]')).value), |
cumulatedSolarRechargeEnergy: 0, |
||||||
dailyAscendingElevation: Number((<HTMLInputElement>container.querySelector('[name=daily-elevation]')).value), |
cumulatedMotorConsumption: 0 |
||||||
}; |
}; |
||||||
let simulationResult = startSimulation(parameters); |
|
||||||
|
|
||||||
let resultsContainer = container.querySelector('.simulation-results'); |
let remainingBatteryCharge = vehicle.batteryCapacity; |
||||||
|
|
||||||
let batteryChargeGraph = resultsContainer.querySelector('.battery-charge-graph'); |
let outing: Outing = { distance: 0, ascendingElevation: 0 }; |
||||||
let batteryChargeGraphSvg = batteryChargeGraph.querySelector('svg'); |
|
||||||
|
|
||||||
let coordinates = ''; |
for(let day = 0; day < 365; ++day) { |
||||||
let view = [1000, 300]; |
for(let hour = 0; hour < 24; ++hour) { |
||||||
let hoursInYear = 365 * 24; |
let hourIdx = day * 24 + hour; |
||||||
for(let dayOfYear = 0; dayOfYear < 365; ++dayOfYear) { |
|
||||||
for(let hourOfDay = 0; hourOfDay < 24; ++hourOfDay) { |
|
||||||
let h = dayOfYear * 24 + hourOfDay; |
|
||||||
let batteryLevel = simulationResult.batteryLevel[h]; |
|
||||||
|
|
||||||
if(h == 0) coordinates += 'M'; |
planning.getOuting(day % 7, hour, outing); |
||||||
else if(h == 1) coordinates += ' L'; |
|
||||||
else coordinates += ' '; |
|
||||||
|
|
||||||
coordinates += Math.round(h * view[0] / hoursInYear)+','+Math.round(view[1] - batteryLevel * view[1] / parameters.batteryCapacity); |
let consumption = vehicle.motorConsumption(outing.distance, outing.ascendingElevation); |
||||||
} |
let production = vehicle.solarPower(solarIrradiance[hourIdx]) * 1.0; // produced energy in Wh is equal to power (W) multiplied by time (h)
|
||||||
|
|
||||||
|
let solarCharge = production * vehicle.batteryEfficiency; |
||||||
|
|
||||||
|
// TODO: we should keep a margin because real users will recharge before they reach the bare minimum required for an outing
|
||||||
|
remainingBatteryCharge += solarCharge - consumption; |
||||||
|
if(remainingBatteryCharge > vehicle.batteryCapacity) { |
||||||
|
solarCharge -= remainingBatteryCharge - vehicle.batteryCapacity; |
||||||
|
remainingBatteryCharge = vehicle.batteryCapacity;
|
||||||
|
} |
||||||
|
else if(remainingBatteryCharge <= 0) { |
||||||
|
let rechargeEnergy = vehicle.batteryCapacity - remainingBatteryCharge; |
||||||
|
remainingBatteryCharge += rechargeEnergy; |
||||||
|
result.cumulatedGridRechargeEnergy += rechargeEnergy; |
||||||
|
result.gridChargeCount += 1; |
||||||
|
} |
||||||
|
|
||||||
|
result.cumulatedMotorConsumption += consumption; |
||||||
|
result.cumulatedSolarRechargeEnergy += solarCharge; |
||||||
|
|
||||||
|
result.batteryLevel[hourIdx] = remainingBatteryCharge; |
||||||
|
}
|
||||||
} |
} |
||||||
let path = document.createElementNS('http://www.w3.org/2000/svg','path'); |
|
||||||
path.setAttribute('class','graph'); |
|
||||||
path.setAttribute('d', coordinates); |
|
||||||
path.setAttribute('shape-rendering', 'optimizeQuality') |
|
||||||
batteryChargeGraphSvg.append(path); |
|
||||||
|
|
||||||
resultsContainer.classList.toggle('is-hidden', false); |
return result; |
||||||
}); |
} |
||||||
}); |
} |
||||||
|
@ -0,0 +1,77 @@ |
|||||||
|
namespace SvgDrawing { |
||||||
|
export interface Rect { |
||||||
|
x: number; |
||||||
|
y: number; |
||||||
|
width: number; |
||||||
|
height: number; |
||||||
|
} |
||||||
|
|
||||||
|
export class Viewport { |
||||||
|
constructor(public logical: Rect, public view: Rect) {} |
||||||
|
|
||||||
|
xLogicalToView(x: number) { return (x - this.logical.x) / this.logical.width * this.view.width + this.view.x; } |
||||||
|
yLogicalToView(y: number) { return (y - this.logical.y) / this.logical.height * this.view.height + this.view.y; } |
||||||
|
} |
||||||
|
|
||||||
|
export class SvgElement { |
||||||
|
public viewport: Viewport; |
||||||
|
|
||||||
|
constructor(private htmlElement: HTMLElement) { |
||||||
|
let viewBox = htmlElement.getAttribute('viewBox').split(' '); |
||||||
|
let r: Rect = { x: Number(viewBox[0]), y: Number(viewBox[1]), width: Number(viewBox[2]), height: Number(viewBox[3]) }; |
||||||
|
this.viewport = new Viewport(r, { x: r.x, y: r.y + r.height, width: r.width, height: -r.height }); |
||||||
|
} |
||||||
|
|
||||||
|
graph(y: number[]): SVGPathElement; |
||||||
|
graph(x: number[], y: number[]): SVGPathElement; |
||||||
|
graph(arg1: number[], arg2?: number[]) { |
||||||
|
let x: number[] | null = arg1; |
||||||
|
let y: number[] = arg2; |
||||||
|
if(!y) { |
||||||
|
y = arg1; |
||||||
|
x = null; |
||||||
|
} |
||||||
|
|
||||||
|
let num = y.length; |
||||||
|
console.assert(!x || num == x.length); |
||||||
|
|
||||||
|
if(num <= 1) return null; |
||||||
|
|
||||||
|
let xStep = 6; |
||||||
|
|
||||||
|
let coordinates = 'M'+Math.round(this.viewport.xLogicalToView(x? x[0] : 0))+','+Math.round(this.viewport.yLogicalToView(y[0])); |
||||||
|
coordinates += ' L'; |
||||||
|
let lineStartX = x ? x[0] : 0; |
||||||
|
let prevX = lineStartX; |
||||||
|
let prevY = y[0]; |
||||||
|
let yDir = y[1] > y[0] ? 1 : -1; |
||||||
|
|
||||||
|
let count = 0; |
||||||
|
for(let idx = 0; idx < num; ++idx) { |
||||||
|
let isLast = (idx == num - 1); |
||||||
|
|
||||||
|
let newX = x ? x[idx] : idx; |
||||||
|
let newY = y[idx]; |
||||||
|
let dir = isLast ? 0 : (y[idx+1] > newY ? 1 : -1); |
||||||
|
|
||||||
|
if(newX >= lineStartX + xStep || dir != yDir || isLast) { |
||||||
|
coordinates += Math.round(this.viewport.xLogicalToView(newX))+','+Math.round(this.viewport.yLogicalToView(newY)); |
||||||
|
if(!isLast) coordinates += ' '; |
||||||
|
lineStartX = newX; |
||||||
|
yDir = isLast ? 0 : (y[idx+1] > newY ? 1 : -1); |
||||||
|
++count; |
||||||
|
} |
||||||
|
prevY = newY; |
||||||
|
} |
||||||
|
|
||||||
|
console.log(count); |
||||||
|
|
||||||
|
let path = document.createElementNS('http://www.w3.org/2000/svg','path'); |
||||||
|
path.setAttribute('class','graph'); |
||||||
|
path.setAttribute('d', coordinates); |
||||||
|
this.htmlElement.append(path); |
||||||
|
|
||||||
|
return path; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue