diff --git a/simulator/src/app.scss b/simulator/src/app.scss index fdadf5b..29ba6fc 100644 --- a/simulator/src/app.scss +++ b/simulator/src/app.scss @@ -51,10 +51,27 @@ svg text { .battery-charge-graph { margin-top: 2rem; + overflow-x: auto; } -.battery-charge-graph { +.battery-charge-graph svg { + min-height: 300px; +} + +.battery-charge-graph line { stroke: black; +} + +.battery-charge-graph .graph { + stroke: #090; fill: none; stroke-width: 1px; } + +.battery-charge-graph .graph.grid-recharge { + stroke: #00c; +} + +.battery-charge-graph text { + font-size: 10px; +} diff --git a/simulator/src/simulator-ui.ts b/simulator/src/simulator-ui.ts index 11f813c..5d21671 100644 --- a/simulator/src/simulator-ui.ts +++ b/simulator/src/simulator-ui.ts @@ -74,8 +74,34 @@ document.addEventListener('DOMContentLoaded', function() { let resultsContainer = container.querySelector('.simulation-results'); let batteryChargeGraph = new SvgDrawing.SvgElement(resultsContainer.querySelector('.battery-charge-graph svg')); - batteryChargeGraph.viewport.setLogical({ x: 0, y: 0, width: 365*24, height: parameters.batteryCapacity }); - batteryChargeGraph.graph(simulationResult.batteryLevel); + + batteryChargeGraph.clear(); + + let marginTop = 20; + let marginBottom = 20; + let marginLeft = 40; + let marginRight = 20; + + batteryChargeGraph.line({ x: marginLeft, y: marginBottom }, { x: batteryChargeGraph.width - marginRight, y: marginBottom }); + batteryChargeGraph.line({ x: marginLeft, y: marginBottom }, { x:marginLeft, y: batteryChargeGraph.height - marginTop }); + + batteryChargeGraph.text({ x: marginLeft-3, y: marginBottom }, '0%', 'end', 0.6); + batteryChargeGraph.text({ x: marginLeft-3, y: batteryChargeGraph.height - marginTop }, '100%', 'end', 0.6); + + batteryChargeGraph.viewport.setData({ x: 0, y: 0, width: 365*24, height: parameters.batteryCapacity }); + batteryChargeGraph.viewport.setView({ x: marginLeft, y: batteryChargeGraph.height - marginBottom, width: batteryChargeGraph.width - (marginLeft+marginRight), height: -batteryChargeGraph.height+(marginTop+marginBottom) }); + batteryChargeGraph.graph(simulationResult.batteryLevel, simulationResult.batteryLevel.map(x => x == 0 ? 1 : 0), [{className: ''}, {className: 'grid-recharge'}]); + let months = ['Jan', 'Fev', 'Mar', 'Avr', 'Mai', 'Jui', 'Jui', 'Aou', 'Sep', 'Oct', 'Nov', 'Dec']; + let monthWidth = 365*24/12 + for(let month = 0; month < 12; ++month) { + batteryChargeGraph.text({ x: (month+0.5)*monthWidth, y: 0 }, months[month], 'middle', -0.1); + } + + batteryChargeGraph.viewport.setData({ x: 0, y: 0, width: 365*24, height: 1 }); + batteryChargeGraph.viewport.setView({ x: marginLeft, y: batteryChargeGraph.height - marginBottom, width: batteryChargeGraph.width - (marginLeft+marginRight), height: -15 }); + for(let month = 0; month < 13; ++month) { + batteryChargeGraph.line({ x: month*monthWidth, y: 0 }, { x: month*monthWidth, y: -1 }); + } resultsContainer.classList.toggle('is-hidden', false); }); diff --git a/simulator/src/simulator.ts b/simulator/src/simulator.ts index 6191fb6..d3f402f 100644 --- a/simulator/src/simulator.ts +++ b/simulator/src/simulator.ts @@ -92,11 +92,15 @@ namespace Simulator { // TODO: we should keep a margin because real users will recharge before they reach the bare minimum required for an outing remainingBatteryCharge += solarCharge - consumption; + + let gridRecharge = false; if(remainingBatteryCharge > vehicle.batteryCapacity) { solarCharge -= remainingBatteryCharge - vehicle.batteryCapacity; remainingBatteryCharge = vehicle.batteryCapacity; } else if(remainingBatteryCharge <= 0) { + // TODO: detect if battery capacity is too low for a single outing, abort simulation and display an explanation for the user + gridRecharge = true; let rechargeEnergy = vehicle.batteryCapacity - remainingBatteryCharge; remainingBatteryCharge += rechargeEnergy; result.cumulatedGridRechargeEnergy += rechargeEnergy; @@ -106,8 +110,8 @@ namespace Simulator { result.cumulatedMotorConsumption += consumption; result.cumulatedSolarRechargeEnergy += solarCharge; - result.batteryLevel[hourIdx] = remainingBatteryCharge; - } + result.batteryLevel[hourIdx] = gridRecharge ? 0 : remainingBatteryCharge; + } } return result; diff --git a/simulator/src/svg-drawing.ts b/simulator/src/svg-drawing.ts index e8b778b..e8a12e3 100644 --- a/simulator/src/svg-drawing.ts +++ b/simulator/src/svg-drawing.ts @@ -40,46 +40,101 @@ namespace SvgDrawing { } export class Viewport { - private invLogicalW: number = 0; - private invLogicalH: number = 0; + private invDataW: number = 0; + private invDataH: number = 0; - constructor(private logical: Rect, private view: Rect) { this.update(); } + constructor(private data: Rect, private view: Rect) { this.update(); } - setLogical(r: Rect) { this.logical = r; this.update(); } + setData(r: Rect) { this.data = r; this.update(); } + setView(r: Rect) { this.view = r; this.update(); } - 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; } + xDataToView(x: number) { return (x - this.data.x) / this.data.width * this.view.width + this.view.x; } + yDataToView(y: number) { return (y - this.data.y) / this.data.height * this.view.height + this.view.y; } - logicalToView(p: Point, out_point: Point) { - out_point.x = (p.x - this.logical.x) * this.invLogicalW * this.view.width + this.view.x; - out_point.y = (p.y - this.logical.y) * this.invLogicalH * this.view.height + this.view.y; + dataToView(p: Point, out_point: Point) { + out_point.x = (p.x - this.data.x) * this.invDataW * this.view.width + this.view.x; + out_point.y = (p.y - this.data.y) * this.invDataH * this.view.height + this.view.y; } private update() { - this.invLogicalW = 1.0 / this.logical.width; - this.invLogicalH = 1.0 / this.logical.height; + this.invDataW = 1.0 / this.data.width; + this.invDataH = 1.0 / this.data.height; } } + export interface LineStyle { + className: string; + } + export class SvgElement { public viewport: Viewport; + public width: number; + public height: number; 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 }); + this.width = r.width; + this.height = r.height; + } + + clear() { + this.htmlElement.querySelectorAll('*').forEach(elt => elt.remove()); + } + + line(start: Point, end: Point): SVGLineElement { + let line = document.createElementNS('http://www.w3.org/2000/svg','line'); + line.setAttribute('x1', this.viewport.xDataToView(start.x).toString()); + line.setAttribute('y1', this.viewport.yDataToView(start.y).toString()); + line.setAttribute('x2', this.viewport.xDataToView(end.x).toString()); + line.setAttribute('y2', this.viewport.yDataToView(end.y).toString()); + this.htmlElement.append(line); + return line; + } + + text(position: Point, value: string, anchor?: string, verticalAlign?: number): SVGTextElement { + let text = document.createElementNS('http://www.w3.org/2000/svg','text'); + text.setAttribute('x', this.viewport.xDataToView(position.x).toString()); + text.setAttribute('y', this.viewport.yDataToView(position.y).toString()); + if(anchor !== undefined) text.setAttribute('text-anchor', anchor); + if(verticalAlign !== undefined) text.setAttribute('dy', (1.0-verticalAlign) + 'em'); + text.appendChild(document.createTextNode(value)); + this.htmlElement.append(text); + return text; } - graph(y: number[]): SVGPathElement; - graph(x: number[], y: number[]): SVGPathElement; - graph(arg1: number[], arg2?: number[]) { + graph(y: number[]): SVGPathElement[]; + graph(x: number[], y: number[]): SVGPathElement[]; + graph(y: number[], indices: number[], styles: LineStyle[]): SVGPathElement[]; + graph(x: number[], y: number[], indices: number[], styles: LineStyle[]): SVGPathElement[]; + graph(arg1: number[], arg2?: number[], arg3?: number[] | LineStyle[], arg4?: LineStyle[]): SVGPathElement[] { + let indices: number[] | null = null; + let styles: LineStyle[] | null = null; + if(arg3) { + if(arg4) { + indices = arg3; + styles = arg4; + } + else { + indices = arg2; + styles = arg3; + } + } + + if(styles == null) { + styles = [{className: ''}]; + } + let read = (idx: number, out_point: Point) => { out_point.x = arg1[idx]; out_point.y = arg2[idx]; return true; }; - if(!arg2) { + let reset = (idx: number) => {}; + + if(!arg2 || (arg3 && typeof(arg3[0]) !== 'number')) { read = (idx: number, out_point: Point) => { out_point.x = idx; out_point.y = arg1[idx]; @@ -100,7 +155,6 @@ namespace SvgDrawing { let dp = Math.cos(optimizeCurveAngle/180*Math.PI); let lastDrawnPoint: Point = { x: 0, y: 0 }; - read(0, lastDrawnPoint); let nextPoint: Point = { x: 0, y: 0 }; let dir: Point = { x: 0, y: 0 }; @@ -108,6 +162,12 @@ namespace SvgDrawing { let nextSegDir: Point = { x: 0, y: 0 }; let perp: Point = { x: 0, y: 0 }; + let rawReset = reset; + reset = (idx: number) => { + rawReset(idx); + read(idx, lastDrawnPoint); + }; + read = (idx: number, out_point: Point) => { rawRead(idx, out_point); if(idx == 0 || idx == num - 1) return true; @@ -140,31 +200,48 @@ namespace SvgDrawing { let startTime = performance.now(); let count = 0; - let logicalPoint: Point = { x: 0, y: 0 }; - let viewPoint: Point = { x: 0, y: 0 }; - read(0, logicalPoint); - this.viewport.logicalToView(logicalPoint, viewPoint); - - let coordinates = 'M'+Math.round(viewPoint.x)+','+Math.round(viewPoint.y); - coordinates += ' L'; + let paths = []; - for(let idx = 0; idx < num; ++idx) { - if(read(idx, logicalPoint)) { - this.viewport.logicalToView(logicalPoint, viewPoint); - coordinates += Math.round(viewPoint.x)+','+Math.round(viewPoint.y)+' '; - count += 1; + for(let styleIdx = 0; styleIdx < styles.length; ++styleIdx) { + let dataPoint: Point = { x: 0, y: 0 }; + let viewPoint: Point = { x: 0, y: 0 }; + + let coordinates = ''; + + let open = false; + for(let idx = 0; idx < num; ++idx) { + let includePoint = !indices || indices[idx] == styleIdx; + + if(!open && includePoint) { + reset(idx); + read(idx, dataPoint); + this.viewport.dataToView(dataPoint, viewPoint); + let space = coordinates == '' ? '' : ' '; + coordinates += space+'M'+Math.round(viewPoint.x)+','+Math.round(viewPoint.y) + ' L'; + count += 1; + } + else if(open && (read(idx, dataPoint) || !includePoint)) { + this.viewport.dataToView(dataPoint, viewPoint); + coordinates += ' '+Math.round(viewPoint.x)+','+Math.round(viewPoint.y); + count += 1; + } + + open = includePoint; } + + let style = styles[styleIdx]; + let path = document.createElementNS('http://www.w3.org/2000/svg','path'); + path.setAttribute('class', 'graph' + (style.className == '' ? '' : ' ' + style.className)); + path.setAttribute('d', coordinates); + this.htmlElement.append(path); + + paths.push(path); } - let path = document.createElementNS('http://www.w3.org/2000/svg','path'); - path.setAttribute('class','graph'); - path.setAttribute('d', coordinates); - this.htmlElement.append(path); - let endTime = performance.now(); console.log("graph: " + count + " points, " + (endTime - startTime) + "ms"); - return path; + return paths; } } } diff --git a/simulator/tools/purify.js b/simulator/tools/purify.js index 42f122b..321631c 100644 --- a/simulator/tools/purify.js +++ b/simulator/tools/purify.js @@ -8,7 +8,7 @@ let css = ['../.intermediate/simulator.css']; let options = { output: '../www/simulator.css', - whitelist: ['is-multiple', 'is-loading', 'is-narrow', 'is-active', 'climate-zone', 'is-max-desktop', 'is-max-widescreen'], + whitelist: ['is-multiple', 'is-loading', 'is-narrow', 'is-active', 'climate-zone', 'grid-recharge', 'is-max-desktop', 'is-max-widescreen', 'line'], minify: false, info: false };