# 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. # You also will need d3js v4 documentation : https://d3js.org/ # We're using a custom build of 4.9.1, one line changed, see d3-custom.js # Event bubbling cannot trigger two rects unless we make an event dispatcher, # and d3's brush is stopping propagation, as it should by default. ############################################################################### const GOLDEN_RATIO = 2 / (1 + Math.sqrt(5)) # Between 0 and 1 (0.618...) ############################################################################### class Target (@slug, @name, @config) -> @active = @config.active ############################################################################### 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. (both at midnight) """ API_TIME_FORMAT = "YYYY-MM-DDTHH:mm:ss" (@configuration) -> console.info """ _ _ _ _ ____ | | | | ___| (_) ___ | _ \\ _ __ ___ _ __ __ _ | |_| |/ _ \\ | |/ _ \\| |_) | '__/ _ \\| '_ \\ / _` | | _ | __/ | | (_) | __/| | | (_) | |_) | (_| | |_| |_|\\___|_|_|\\___/|_|_ |_|_ \\___/| .__/ \\__,_| | |__ _ _ / ___| _ \\| _ \\| _ \\_| | '_ \\| | | | | | | | | | |_) | |_) | | |_) | |_| | | |___| |_| | __/| __/ |_.__/ \\__, | \\____|____/|_| |_| |___/ The full source of this website is available at : https://gitlab.irap.omp.eu/CDPP/SPACEWEATHERONLINE """ # HelioPropa by CDPP (mushed 'cause we need to escape backslashes) @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) """ @orbits = new Orbits(@configuration.orbits_container, @configuration) # Default time interval is from one year ago to one week ahead. # We set the h/m/s to zero to benefit from a daily cache. @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(API_TIME_FORMAT) stopped_at = @stopped_at.format(API_TIME_FORMAT) console.info "Setting time interval from #{started_at} to #{stopped_at}…" @loadAndCreatePlots(started_at, stopped_at) 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 # enableAllTargets: -> # for slug, target of @targets # enableTarget(slug) # this enableTarget: (target_slug) -> timeSeries.forEach((ts) ~> ts.show() if ts.target.slug == target_slug && @parameters[ts.parameter].active) @targets[target_slug].active = true this disableTarget: (target_slug) -> timeSeries.forEach((ts) -> ts.hide() if ts.target.slug == target_slug) @targets[target_slug].active = false this resize: -> @orbits?.resize(); timeSeries.forEach((ts) -> ts.resize()) showLoader: -> $("\#plots_loader").show(); hideLoader: -> $("\#plots_loader").hide(); 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 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) ) ) loadAndCreatePlots: (started_at, stopped_at) -> @showLoader() active_targets = [@targets[k] for k of @targets when @targets[k].active] active_targets.forEach((target) ~> console.info "Loading CSV data of #{target.name}…" targetButton = $(".targets-filters .target.#{target.slug}") targetButton.addClass('loading') @loadData(target.slug, started_at, stopped_at).then( (data) ~> console.info "Loaded CSV data of #{target.name}." @createTimeSeries(target, data) @orbits.initOrbiter(target.slug, target.config, data['hci']) targetButton.removeClass('loading') @hideLoader() , (error) -> console.error("Failed loading CSV data of #{target.name}.", error) ) ) clearPlots: -> @orbits.clear() timeSeries.forEach((ts) -> ts.clear()) @orbits = null timeSeries = [] # do we de-reference all existing TimeSeries ? #memleak? this timeSeries = [] # deprecated (was for scoping) ; use @property with ~> 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) ~> # returning true may be faster ts.options['onMouseOver'] = -> timeSeries.forEach((ts2) -> ts2.showCursor()) ; true ts.options['onMouseOut'] = -> timeSeries.forEach((ts2) -> ts2.hideCursor()) ; true ts.options['onMouseMove'] = (t) ~> timeSeries.forEach((ts2) -> ts2.moveCursor(t)) @orbits?.moveToDate(t) ; true ts.options['onBrushEnd'] = (sta, sto) ~> @resizeDomain(sta, sto) ; true ts.options['onDblClick'] = ~> @resetZoom() ; $("\#zoom_controls_help")?.remove() ; true ) 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.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.hide() if ts.parameter == parameter_slug) this getDomain: -> if @current_started_at? and @current_stopped_at? return [@current_started_at, @current_stopped_at] return [@started_at, @stopped_at] 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 distinct start and stop dates." return @current_started_at = started_at @current_stopped_at = stopped_at if (@started_at <= started_at <= @stopped_at) and (@started_at <= stopped_at <= @stopped_at) then console.info "Resizing the temporal domain from #{started_at} to #{stopped_at} without fetching new data…" # We first resize the hidden time series and only afterwards we resize # the visible ones, for a smoother transition. timeSeries.forEach((ts) -> if not ts.visible then ts.zoomIn(started_at, stopped_at)) timeSeries.forEach((ts) -> if ts.visible then ts.zoomIn(started_at, stopped_at)) @orbits.resizeDomain started_at, stopped_at return # fetch new data and remake the plots @clearPlots() @loadAndCreatePlots(started_at, stopped_at) resetZoom: -> timeSeries.forEach((ts) -> ts.resetZoom()) @orbits.resetZoom() ############################################################################### ############################################################################### export class TimeSeries # Time in x-axis # Data in y-axis (@parameter, @title, @target, data, @visible, @container, @options = {}) -> # parameter : slug of the parameter to observe, like magn or pdyn # title : string, more descriptive, shown on the left of the Y axis # target : target object, like described in configuration # data : list of {x: , y: } @setData(data) @init() toString: -> "#{@title} of #{@target.name}" setData: (data) -> @data = data # and pre-compute extents for performance when zooming @xDataExtent = d3.extent(@data, (d) -> d.x) @yDataExtent = d3.extent(@data, (d) -> d.y) init: -> console.info "Initializing plot of #{@}…" @margin = { top: 30, right: 20, bottom: 30, left: 80 } @xScale = d3.scaleTime().domain(@xDataExtent) @yScale = d3.scaleLinear().domain(@yDataExtent) @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 + ')') clipId = "ts-clip-#{@parameter}-#{@target.slug}" @clip = @svg.append("defs").append("svg:clipPath") .attr("id", clipId) .append("svg:rect") .attr("x", 0) .attr("y", 0) @pathWrapper = @plotWrapper.append('g') @pathWrapper.attr("clip-path", "url(\##{clipId})") @path = @pathWrapper.append('path') .datum(@data) .classed('line', true) @brush = @plotWrapper.append("g") .attr("class", "brush") # deprecated, use brush's 'overlay' child @mouseCanvas = @plotWrapper.append("rect") .style("fill", "none") @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() RATIO = GOLDEN_RATIO * GOLDEN_RATIO * GOLDEN_RATIO * GOLDEN_RATIO resize: -> width = Math.ceil($(@container).width() - @margin.left - @margin.right) height = Math.ceil(RATIO * width) @plotWidth = width @plotHeight = height console.debug("Resizing #{@}: #{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) @clip.attr("width", width) .attr("height", height) @path.attr('d', @line) @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) if not @brushFunction? console.debug "Creating the zooming brush for #{@}…" # looks like d3.brush handles its own resizing on window.resize @brushFunction = d3.brushX() .extent([[0, 0], [width, height]]) .handleSize(0) .on("end", @onBrushEnd) # .on("start", @onBrushStart) # .on("move", @onBrushMove) @brush.call(@brushFunction) # We're also adding our own cursor events to the brush's overlay, # because it captures events and a rect cannot contain another. @svg.select(".brush .overlay") .on("mouseover.swapp", @onMouseOver) .on("mouseout.swapp", @onMouseOut) .on("mousemove.swapp", @onMouseMove) .on("dblclick.swapp", @onDoubleClick) unless @visible then @hide() this clear: -> $(@svg.node()).remove() @visible = false show: -> $(@svg.node()).show() @visible = true hide: -> $(@svg.node()).hide() @visible = false onMouseMove: ~> x = @xScale.invert(d3.mouse(@mouseCanvas.node())[0]) if @options.onMouseMove? then @options.onMouseMove(x) else @moveCursor(x) onMouseOver: ~> if @options.onMouseOver? then @options.onMouseOver() else @showCursor() onMouseOut: ~> if @options.onMouseOut? then @options.onMouseOut() else @hideCursor() onDoubleClick: ~> if @options.onDblClick? then @options.onDblClick() else @resetZoom() onBrushEnd: ~> s = d3.event.selection if s minmax = [s[0], s[1]].map(@xScale.invert, @xScale) @brush.call(@brushFunction.move, null) # some voodoo to hide the brush if @options.onBrushEnd? then @options.onBrushEnd(minmax[0], minmax[1]) else @zoomIn(minmax[0], minmax[1]) zoomIn: (startDate, stopDate) -> console.debug "Zooming in #{@} from #{startDate} to #{stopDate}." [minDate, maxDate] = @xDataExtent if startDate < minDate then startDate = minDate if stopDate > maxDate then stopDate = maxDate @xScale.domain([startDate, stopDate]) @applyZoom() resetZoom: -> @xScale.domain(@xDataExtent) @yScale.domain(@yDataExtent) @applyZoom() applyZoom: -> if @visible console.debug("Applying zoom to visible #{@}…") t = @svg.transition().duration(750) @svg.select('.x.axis').transition(t).call(@xAxis); @svg.select('.y.axis').transition(t).call(@yAxis); @path.transition(t).attr('d', @line) else console.debug("Applying zoom to hidden #{@}…") @svg.select('.x.axis').call(@xAxis); @svg.select('.y.axis').call(@yAxis); @path.attr('d', @line) 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 plot of orbits…" @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.info "Initializing orbit of #{config.name}…" 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 clear: -> $(@svg.node()).remove() resize: -> width = Math.ceil($(@container).width() - @margin.left - @margin.right) height = Math.ceil(1.0 * width) console.debug("Resizing orbits : #{width} × #{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, width, height) @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, height) -> console.debug("Resizing orbit of #{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.warn("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) this resizeDomain: (started_at, stopped_at) -> for slug, config of @orbiters el = @orbitersElements[slug] data = @data[slug].filter (d) -> started_at <= d.t <= stopped_at el['orbit_section'].datum(data) el['orbit_section'].attr('d', el['orbit_line']) resetZoom: -> for slug, config of @orbiters el = @orbitersElements[slug] el['orbit_section'].datum(@data[slug]) el['orbit_section'].attr('d', el['orbit_line'])