# Livescript transpiles to javascript, and is easier on the eyes and brain. # Get the `lsc` binary from here : http://livescript.net # It is quite close to Python, syntax-wise, and full of sugar. # To transpile this file to javascript, and generate `swapp.js` : # $ lsc --compile swapp.ls # You can have it watch for changes and transpile automatically : # $ lsc --watch --compile swapp.ls # All the "javascript" code is in this file, except for inline scripts in # templates, such as `home.html.jinja2`. # Note: We use Promises and ES6 whenever relevant. ############################################################################### const GOLDEN_RATIO = 2 / (1 + Math.sqrt(5)) # Between 0 and 1 (0.618...) ############################################################################### class Target (@slug, @name, @config) -> @active = true # by default, all targets are active at first ############################################################################### export class SpaceWeather """ The main app, instanciated from an inline script. It defaults to an interval starting a year ago, and ending in seven days. """ (@configuration) -> console.info "Creating HelioPropa app...", @configuration @targets = {} configs = [@configuration.targets[k] for k of @configuration.targets] configs.forEach((target_config) ~> @targets[target_config.slug] = new Target(target_config.slug, target_config.name, target_config) ) @parameters = {} @configuration['parameters'].forEach((p) ~> @parameters[p['id']] = p ) init: -> """ This is called by the inline bootstrap javascript code. This ain't in the constructor because it might return a Promise later on. (for the loader, for example) """ active_targets = [ @targets[k] for k of @targets when @targets[k].config.active ] @orbits = new Orbits(@configuration.orbits_container, @configuration) # Set the h/m/s to zero so that files are cached per whole days @started_at = moment().subtract(1, 'years').hours(0).minutes(0).seconds(0) @stopped_at = moment().add(7, 'days').hours(0).minutes(0).seconds(0) started_at = @started_at.format("YYYY-MM-DDTHH:mm:ss") stopped_at = @stopped_at.format("YYYY-MM-DDTHH:mm:ss") active_targets.forEach((target) ~> @loadData(target.slug, started_at, stopped_at).then( (data) ~> console.info "Loaded CSV data for #{target.slug}." @createTimeSeries(target, data) @orbits.initOrbiter(target.slug, target.config, data['hci']) , (error) -> console.error('Failed to load CSV data.', error) ) ) window.addEventListener 'resize', ~> @resize() buildDataUrlForTarget: (target_slug, started_at, stopped_at) -> url = @configuration['api']['data_for_interval'] url = url.replace('', target_slug) url = url.replace('', started_at) url = url.replace('', stopped_at) url addTarget: (target) -> @targets[target.slug] = target this showAllTargets: -> for slug, target of @targets showTarget(slug) this showTarget: (target_slug) -> timeSeries.forEach((ts) ~> $(ts.svg.node()).show() if ts.target.slug == target_slug && @parameters[ts.parameter].active) @targets[target_slug].active = true this hideTarget: (target_slug) -> timeSeries.forEach((ts) -> $(ts.svg.node()).hide() if ts.target.slug == target_slug) @targets[target_slug].active = false this resize: -> @orbits?.resize(); timeSeries.forEach((ts) -> ts.resize()) loadData: (target_slug, started_at, stopped_at) -> """ Load the data as CSV for the specified target and interval, and return it in a Promise. """ sw = this promise = new Promise((resolve, reject) -> url = sw.buildDataUrlForTarget(target_slug, started_at, stopped_at) d3.csv(url, (csv) -> timeFormat = d3.timeParse('%Y-%m-%dT%H:%M:%S%Z') data = {'hci': []}; configuration['parameters'].forEach((parameter) -> data[parameter['id']] = [] ) csv.forEach((d) -> dtime = timeFormat(d['time']) configuration['parameters'].forEach((parameter) -> id = parameter['id'] data[id].push({x: dtime, y: parseFloat(d[id])}) ) if (d['xhci'] && d['yhci']) data['hci'].push({t: dtime, x: parseFloat(d['xhci']), y: parseFloat(d['yhci'])}); ) resolve(data) ) ) promise timeSeries = [] # Not sure why this ain't an instance prop. Probably should. createTimeSeries: (target, data) -> @configuration['parameters'].forEach((parameter) ~> container = @configuration['time_series_container'] id = parameter['id'] ; title = parameter['title'] if id not of data then console.error("No data for id '#{id}'.", data) timeSeries.push(new TimeSeries(id, title, target, data[id], @parameters[id].active, container)) ) timeSeries.forEach((ts) ~> ts.options['onMouseOver'] = -> timeSeries.forEach((ts2) -> ts2.showCursor()) ts.options['onMouseOut'] = -> timeSeries.forEach((ts2) -> ts2.hideCursor()) ts.options['onMouseMove'] = (t) ~> timeSeries.forEach((ts2) -> ts2.moveCursor(t)) @orbits?.moveToDate(t) ) enableParameter: (parameter_slug) -> if parameter_slug not of @parameters then console.error("Unknown parameter #{parameter_slug}.") @parameters[parameter_slug].active = true timeSeries.forEach((ts) ~> $(ts.svg.node()).show() if ts.parameter == parameter_slug && @targets[ts.target.slug].active) this disableParameter: (parameter_slug) -> if parameter_slug not of @parameters then console.error("Unknown parameter #{parameter_slug}.") @parameters[parameter_slug].active = false timeSeries.forEach((ts) -> $(ts.svg.node()).hide() if ts.parameter == parameter_slug) this resizeDomain: (started_at, stopped_at) -> if stopped_at < started_at tmp = started_at started_at = stopped_at stopped_at = started_at if started_at == stopped_at console.warn "Please provide different start and stop dates." return if (@started_at <= started_at <= @stopped_at) and (@started_at <= stopped_at <= @stopped_at) console.info "Resizing the temporal domain without fetching new data..." timeSeries.forEach((ts) -> ts.resizeDomain started_at, stopped_at) @orbits.resizeDomain started_at, stopped_at return # todo: fetch new data and remake the plots ############################################################################### ############################################################################### export class TimeSeries # Time in x-axis # Data in y-axis (@parameter, @title, @target, @data, @active, @container, @options = {}) -> # parameter : slug of the parameter to observe, like magn or pdyn # title : string # target : slug of the target, like jupiter or tchouri # data : list of {x: , y: } @init() init: -> console.info "Initializing time series #{@title} of #{@target}..." @margin = { top: 30, right: 20, bottom: 30, left: 80 } @xScale = d3.scaleTime().domain(d3.extent(@data, (d) -> d.x)) @yScale = d3.scaleLinear().domain(d3.extent(@data, (d) -> d.y)) #console.info("Y domain #{@title}", d3.extent(@data, (d) -> d.y)) @xAxis = d3.axisBottom() .tickFormat(d3.timeFormat("%Y-%m-%d")) .ticks(7) @yAxis = d3.axisLeft() .ticks(10) @line = d3.line() .x((d) ~> @xScale(d.x)) .y((d) ~> @yScale(d.y)) @svg = d3.select(@container).append('svg') @svg.attr("class", "#{@parameter} #{@target.slug}") @plotWrapper = @svg.append('g') @plotWrapper.attr('transform', 'translate(' + @margin.left + ',' + @margin.top + ')') @path = @plotWrapper.append('path') .datum(@data) .classed('line', true) @mouseCanvas = @plotWrapper.append("rect") .style("fill", "none") .style("pointer-events", "all") @mouseCanvas .on("mouseover", @onMouseOver) .on("mouseout", @onMouseOut) .on("mousemove", @onMouseMove) @plotWrapper.append('g').classed('x axis', true) @plotWrapper.append('g').classed('y axis', true) @yAxisText = @plotWrapper.append("text") .attr("transform", "rotate(-90)") .attr("dy", "1em") .style("text-anchor", "middle") .text(@title) @yAxisTextTarget = @plotWrapper.append("text") .attr("transform", "rotate(-90)") .attr("dy", "1em") .style("text-anchor", "middle") .style("font-style", "oblique") .text(@target.name) @focus = @plotWrapper.append('g').style("display", "none") @cursorCircle = @focus.append("circle") .attr("class", "cursor-circle") .attr("r", 3) dx = 8 @cursorValueShadow = @focus.append("text") .attr("class", "cursor-text cursor-text-shadow") .attr("dx", dx) .attr("dy", "-.3em") @cursorValue = @focus.append("text") .attr("class", "cursor-text cursor-value") .attr("dx", dx) .attr("dy", "-.3em") @cursorDateShadow = @focus.append("text") .attr("class", "cursor-text cursor-text-shadow") .attr("dx", dx) .attr("dy", "1em") @cursorDate = @focus.append("text") .attr("class", "cursor-text cursor-date") .attr("dx", dx) .attr("dy", "1em") @resize() resize: -> width = jQuery(@container).width() - @margin.left - @margin.right height = GOLDEN_RATIO * GOLDEN_RATIO * GOLDEN_RATIO * GOLDEN_RATIO * width @plotWidth = width @plotHeight = height console.log("Resizing time series #{@title} of #{@target}: #{width} x #{height}") @xScale.range([0, width]); @yScale.range([height, 0]); @svg.attr('width', width + @margin.right + @margin.left) .attr('height', height + @margin.top + @margin.bottom) @path.attr('d', @line) # wip, do we need to put this in resize ? @xAxis.scale(@xScale) @yAxis.scale(@yScale) #if width < 600 then @xAxis.ticks(3) else @xAxis.ticks(7, ",f") @xAxis.ticks(Math.floor(width / 90.0)) # not working as expected @yAxis.ticks(Math.floor(height / 18.0)) @svg.select('.x.axis') .attr('transform', 'translate(0,' + height + ')') .call(@xAxis) @svg.select('.y.axis') .call(@yAxis) @yAxisText.attr("y", 20 - @margin.left) .attr("x", 0 - (height / 2)) @yAxisTextTarget.attr("y", 0 - @margin.left) .attr("x", 0 - (height / 2)) @mouseCanvas.attr("width", width) .attr("height", height) unless @active then $(@svg.node()).hide() this resizeDomain: (started_at, stopped_at) -> # fixme onMouseMove: ~> x = @xScale.invert(d3.mouse(@mouseCanvas.node())[0]) if @options.onMouseMove? @options.onMouseMove(x) else @moveCursor(x) onMouseOver: ~> if @options.onMouseOver? @options.onMouseOver() else @showCursor() onMouseOut: ~> if @options.onMouseOut? @options.onMouseOut() else @hideCursor() showCursor: -> @focus.style("display", null) hideCursor: -> @focus.style("display", "none") bisectDate: d3.bisector((d) -> d.x).left # /!\ complex timeFormat: d3.timeFormat("%Y-%m-%d %Hh") moveCursor: (x0) -> i = @bisectDate(@data, x0, 1) d0 = @data[i - 1] d1 = @data[i] return unless d1 and d0 d = if x0 - d0.x > d1.x - x0 then d1 else d0 xx = @xScale(d.x) yy = @yScale(d.y) mirrored = if @plotWidth? and xx > @plotWidth / 2 then true else false dx = 8 # horizontal delta between the dot and the text dx = -1 * dx if mirrored transform = "translate(#{xx}, #{yy})" @cursorCircle.attr("transform", transform) @cursorValue.attr("transform", transform).text(d.y) .attr('text-anchor', if mirrored then 'end' else 'start') .attr("dx", dx) @cursorValueShadow.attr("transform", transform).text(d.y) .attr('text-anchor', if mirrored then 'end' else 'start') .attr("dx", dx) @cursorDate.attr("transform", transform).text(@timeFormat(d.x)) .attr('text-anchor', if mirrored then 'end' else 'start') .attr("dx", dx) @cursorDateShadow.attr("transform", transform).text(@timeFormat(d.x)) .attr('text-anchor', if mirrored then 'end' else 'start') .attr("dx", dx) this ############################################################################### ############################################################################### export class Orbits """ View of the solar system from above, with orbits segments for selected time interval, from real data. """ (@container, @options = {}) -> @init() init: -> console.log "Initializing orbits...", @options @margin = { top: 30, right: 20, bottom: 42, left: 60 } @data = {} # slug => HCI array @orbiters = {} # slug => config @extremum = 1 @xScale = d3.scaleLinear().domain([-1 * @extremum, @extremum]) @yScale = d3.scaleLinear().domain([-1 * @extremum, @extremum]) @xAxis = d3.axisBottom().ticks(10) @yAxis = d3.axisLeft().ticks(10) @svg = d3.select(@container).append('svg') @plotWrapper = @svg.append('g') @plotWrapper.attr('transform', 'translate(' + @margin.left + ',' + @margin.top + ')') @xAxisLine = @plotWrapper.append('g').classed('x axis', true) @yAxisLine = @plotWrapper.append('g').classed('y axis', true) @xAxisTitle = @xAxisLine.append('text').attr('fill', '#000') @xAxisTitle.style("text-anchor", "middle") @xAxisTitle.append('tspan').text('X') # No : https://bugzilla.mozilla.org/show_bug.cgi?id=308338 # @xAxisTitle.append('tspan').attr('baseline-shift', 'sub').text('HEE') # Also, don't use em as dy units @xAxisTitle.append('tspan').attr('dy', '3px').text('HEE').attr('font-size', '8px') @xAxisTitle.append('tspan').attr('dy', '-3px').text(' (AU)') @yAxisTitle = @yAxisLine.append('text').attr('fill', '#000') @yAxisTitle.style("text-anchor", "middle") @yAxisTitle.append('tspan').text('Y') @yAxisTitle.append('tspan').attr('dy', '3px').text('HEE').attr('font-size', '8px') @yAxisTitle.append('tspan').attr('dy', '-3px').text(' (AU)') @yAxisTitle.attr('transform', 'rotate(-90)') @sun = @plotWrapper.append("svg:image") .attr('xlink:href', @options.sun.img) .attr('width', '32px').attr('height', '32px') @sun.append('svg:title').text("Sol") $(@svg.node).hide(); # we'll show it later when there'll be data @resize() orbitersElements: {} initOrbiter: (slug, config, data) -> console.log("Initializing target #{slug}'s orbit...", config, data) if slug of @orbitersElements then throw new Error("Second init of #{slug}") @extremum = Math.max(@extremum, 1.11 * d3.max(data, (d) -> Math.max(Math.abs(d.x), Math.abs(d.y)) )) @xScale = d3.scaleLinear().domain([-1 * @extremum, @extremum]) @yScale = d3.scaleLinear().domain([-1 * @extremum, @extremum]) # The order is important, as it will define the default z-order orbit_ellipse = @plotWrapper.append("svg:ellipse") .classed('orbit orbit_ellipse', true) orbiter = @plotWrapper.append("svg:image") .attr('xlink:href', config['img']) .attr('width', '32px').attr('height', '32px') orbit_line = d3.line() .x((d) ~> @xScale(d.x)) .y((d) ~> @yScale(d.y)) orbit_section = @plotWrapper.append('path') .datum(data) .classed('orbit orbit_section', true) @orbiters[slug] = config @data[slug] = data @orbitersElements[slug] = orbiter: orbiter orbit_ellipse: orbit_ellipse orbit_section: orbit_section orbit_line: orbit_line @resize() $(@svg.node).show(); this resize: -> width = jQuery(@container).width() - @margin.left - @margin.right height = 1.0 * width #console.log("Resizing orbits : #{width} x #{height}") @xScale.range([0, width]); @yScale.range([height, 0]); @svg.attr('width', width + @margin.right + @margin.left) .attr('height', height + @margin.top + @margin.bottom) @sun.attr("x", width / 2 - 16).attr("y", height / 2 - 16) for slug, config of @orbiters @resizeOrbiter(slug, config) @xAxis.scale(@xScale) @yAxis.scale(@yScale) @svg.select('.x.axis') .attr('transform', 'translate(0,' + height + ')') .call(@xAxis) @svg.select('.y.axis') .call(@yAxis) @xAxisTitle.attr("x", width / 2) .attr("y", 37) @yAxisTitle.attr("x", -1 * height / 2) .attr("y", -30) this resizeOrbiter: (slug, config) -> width = jQuery(@container).width() - @margin.left - @margin.right height = 1.0 * width console.log("Resize orbiter #{slug}") el = @orbitersElements[slug] el['orbit_section'].attr('d', el['orbit_line']) a = config['orbit']['a'] b = config['orbit']['b'] c = Math.sqrt(a*a - b*b) cx = (width / 2) - c cy = (height / 2) @yScale.range([0, height]) el['orbit_ellipse'].attr('cx', cx).attr('cy', cy) .attr('rx', @xScale(a) - @xScale(0)) .attr('ry', @yScale(b) - @yScale(0)) # .attr('transform', 'rotate(66,'+(cx+c)+', '+cy+')') @yScale.range([height, 0]) data = @data[slug] el['orbiter'].attr('x', @xScale(data[data.length - 1].x) - 16) el['orbiter'].attr('y', @yScale(data[data.length - 1].y) - 16) this repositionOrbiter: (slug, datum) -> data = @data[slug] datum ?= data[data.length - 1] el = @orbitersElements[slug] el['orbiter'].attr('x', @xScale(datum.x) - 16) el['orbiter'].attr('y', @yScale(datum.y) - 16) this bisectDate: d3.bisector((d) -> d.t).left moveToDate: (t) -> console.log("Trying to move to an undefined date") unless t for slug, el of @orbitersElements data = @data[slug] i = @bisectDate(data, t, 1) d0 = data[i - 1] d1 = data[i] continue unless d1 and d0 d = if t - d0.t > d1.t - t then d1 else d0 @repositionOrbiter(slug, d) # fixme <--(why?) this resizeDomain: (started_at, stopped_at) -> # fixme