view client/src/components/TimeSlider.vue @ 5075:aeb100b4c41b time-sliding

timeslider: prerequisites for time travel * splitting selected time to a) the actual time visible on map (in the toolbar) b) the actual selected time with the timeslider Analogous to an event "ongoing refresh" we have "ongoing timeslide". During this event (a) stays the same according to the information presented on the screen. (b) is not affected. As soon as the loaders settle, the next requests are started with the then "selected time" and the actual time visible on map is set to this new value. Reloading the layers "jumps back to now".
author Thomas Junk <thomas.junk@intevation.de>
date Fri, 13 Mar 2020 10:37:44 +0100
parents 8ec4f1a0db86
children faa1521e597b
line wrap: on
line source

<template>
  <div
    id="slider"
    :class="[
      'd-flex box ui-element rounded bg-white flex-row',
      { expanded: showTimeSlider }
    ]"
    :style="reposition"
  >
    <div id="timeselection" class="d-flex mt-1 mr-1">
      <input
        class="form-control-sm mr-1"
        type="date"
        v-model="dateSelection"
        min="2015-01-01"
        :max="new Date().toISOString().split('T')[0]"
        required
      />
      <input
        type="time"
        min="00:00"
        max="23:59"
        v-model="timeSelection"
        class="form-control-sm"
        required
      />
    </div>
    <div
      id="sliderContainer"
      class="d-flex sliderContainer"
      style="width: 98%;"
    ></div>
    <div
      id="closebutton"
      @click="close"
      class="d-flex box-control mr-0"
      style="width: 2%;"
    >
      <font-awesome-icon icon="times"></font-awesome-icon>
    </div>
  </div>
</template>
<style lang="scss" scoped>
#slider {
  position: absolute;
  bottom: 0;
  min-width: 100vw;
}
#slider.expanded {
  max-height: 100%;
  max-width: 100%;
  margin: 0;
}
input::-webkit-clear-button {
  display: none;
}
// hide clear button on IE
input::-ms-clear {
  display: none;
}
</style>
<script>
/* This is Free Software under GNU Affero General Public License v >= 3.0
 * without warranty, see README.md and license for details.
 *
 * SPDX-License-Identifier: AGPL-3.0-or-later
 * License-Filename: LICENSES/AGPL-3.0.txt
 *
 * Copyright (C) 2020 by via donau
 *   – Österreichische Wasserstraßen-Gesellschaft mbH
 * Software engineering by Intevation GmbH
 *
 * Author(s):
 * Fadi Abbud <fadiabbud@intevation.de>
 */
import { mapState } from "vuex";
import * as d3 from "d3";
import app from "@/main";
import { localeDateString } from "@/lib/datelocalization";
import { format, setHours, setMinutes } from "date-fns";
import debounce from "debounce";

let zoom = null;
let xScale = null;
let xAxis = null;
let currentScaleFactor = 1;

export default {
  name: "timeslider",
  data() {
    return {
      isSelectedTimeHourly: false,
      resizeListenerFunction: null
    };
  },
  watch: {
    ongoingRefresh() {
      if (this.ongoingRefresh) return;
      this.$store.commit("application/setSelectedTime", new Date());
      this.$nextTick(this.rescaleSlider(1));
    },
    ongoingTimeSlide() {
      if (this.ongoingTimeSlide) return;
      this.$store.commit(
        "application/setCurrentVisibleTime",
        this.selectedTime
      );
    }
  },
  computed: {
    ...mapState("application", ["showTimeSlider", "paneSetup"]),
    ...mapState("map", ["ongoingRefresh", "ongoingTimeSlide"]),
    reposition() {
      // reposition time slider in case of opened diagram
      if (["DEFAULT", "COMPARESURVEYS"].indexOf(this.paneSetup) === -1) {
        const height = document.getElementById("main").clientHeight + 1;
        return `bottom: ${height}px`;
      } else {
        return "";
      }
    },
    dateSelection: {
      get() {
        const date = this.$store.state.application.selectedTime;
        return format(date, "YYYY-MM-DD");
      },
      set(value) {
        if (!value) return;
        let date = new Date(value);
        const [hours, minutes] = this.timeSelection.split(":");
        date = setHours(date, hours);
        date = setMinutes(date, minutes);
        this.$store.commit("application/setSelectedTime", date);
        this.rescaleSlider(50);
      }
    },
    timeSelection: {
      get() {
        const time = this.$store.state.application.selectedTime;
        return format(time, "HH:mm");
      },
      set(value) {
        if (!value) return;
        let date = this.selectedTime;
        date = setHours(date, value.split(":")[0]);
        date = setMinutes(date, value.split(":")[1]);
        this.$store.commit("application/setSelectedTime", date);
        this.rescaleSlider(800);
      }
    },
    selectedTime: {
      get() {
        return this.$store.state.application.selectedTime;
      },
      set(value) {
        if (!this.isSelectedTimeHourly) {
          value = setHours(value, 12);
          value = setMinutes(value, 0);
        }
        this.$store.commit("application/setSelectedTime", value);
      }
    }
  },
  methods: {
    close() {
      this.$store.commit("application/showTimeSlider", false);
    },
    rescaleSlider(scaleFactor) {
      const tx =
        -scaleFactor *
          this.getScale()(d3.isoParse(this.selectedTime.toISOString())) +
        document.getElementById("sliderContainer").clientWidth / 2;
      var t = d3.zoomIdentity.translate(tx, 0).scale(scaleFactor);
      this.getScale().domain(t.rescaleX(this.getScale()));
      d3.select(".zoom").call(zoom.transform, t);
    },
    createSlider() {
      const element = document.getElementById("sliderContainer");
      const svgWidth = element ? element.clientWidth : 0,
        svgHeight = 40,
        marginTop = 20,
        marginLeft = 0;

      d3.timeFormatDefaultLocale(localeDateString);
      xScale = this.getScale();
      xAxis = this.getAxes();
      let svg = d3
        .select(".sliderContainer")
        .append("svg")
        .attr("width", svgWidth)
        .attr("height", svgHeight);

      zoom = d3
        .zoom()
        .scaleExtent([0.8, 102000])
        .translateExtent([[0, 0], [svgWidth, svgHeight]])
        .extent([[0, 0], [(svgWidth, svgHeight)]])
        .on("zoom", this.zoomed);

      svg
        .append("g")
        .attr("class", "axis--x")
        .attr("transform", `translate(${marginLeft}, ${marginTop})`)
        .call(xAxis);

      // create rectanlge on the slider area to capture mouse events
      const eventRect = svg
        .append("rect")
        .attr("id", "zoom")
        .attr("class", "zoom")
        .attr("width", svgWidth)
        .attr("height", svgHeight)
        .attr("fill", "white")
        .attr("opacity", 0.2)
        .on("mouseover", () => {
          svg.select(".zoom").attr("cursor", "move");
        });
      eventRect.call(zoom).on("click", this.onClick);

      const toIsoDate = d => {
        return d.toISOString();
      };

      let drag = d3
        .drag()
        .on("start", () => {
          d3.select(".line")
            .raise()
            .classed("active", true);
        })
        .on("drag", this.onDrag)
        .on("end", () => {
          d3.select(".line").classed("active", false);
        });

      // Create cursor to indicate to the selected time
      svg
        .append("rect")
        .attr("class", "line")
        .attr("id", "scrubber")
        .attr("x", xAxis.scale()(d3.isoParse(toIsoDate(this.selectedTime))))
        .attr("y", 0)
        .attr("width", 2)
        .attr("height", svgHeight)
        .attr("stroke", "#17a2b8")
        .attr("stroke-width", 2)
        .attr("opacity", 0.6)
        .on("mouseover", () => {
          svg.select(".line").attr("cursor", "e-resize");
        })
        .call(drag);
    },
    getScale() {
      return d3
        .scaleTime()
        .range([0, document.getElementById("sliderContainer").clientWidth || 0])
        .domain([d3.isoParse(new Date("2015-01-01")), d3.isoParse(new Date())]);
    },
    getAxes() {
      const axesFormat = date => {
        return (d3.timeSecond(date) < date
          ? d3.timeFormat(".%L")
          : d3.timeMinute(date) < date
          ? d3.timeFormat(":%S")
          : d3.timeHour(date) < date
          ? d3.timeFormat("%H:%M")
          : d3.timeDay(date) < date
          ? d3.timeFormat("%H:%M")
          : d3.timeMonth(date) < date
          ? d3.timeWeek(date) < date
            ? d3.timeFormat(app.$gettext("%a %d"))
            : d3.timeFormat(app.$gettext("%b %d"))
          : d3.timeYear(date) < date
          ? d3.timeFormat("%B")
          : d3.timeFormat("%Y"))(date);
      };
      return d3
        .axisBottom(xScale)
        .ticks(12)
        .tickFormat(axesFormat);
    },
    zoomed() {
      let newX = d3.event.transform.rescaleX(xScale);
      currentScaleFactor = d3.event.transform.k;
      const isHourly = currentScaleFactor > 400;
      if (this.isSelectedTimeHourly != isHourly)
        this.isSelectedTimeHourly = isHourly;
      xAxis.scale(newX);
      d3.select(".axis--x").call(xAxis);
      d3.select(".line").attr("x", newX(d3.isoParse(this.selectedTime)));
    },
    onClick() {
      // Extract the click location
      let point = d3.mouse(document.getElementById("zoom")),
        p = { x: point[0], y: point[1] };
      d3.select(".line").attr("x", p.x);
      this.selectedTime = d3.isoParse(xAxis.scale().invert(p.x + 2));
    },
    onDrag() {
      this.selectedTime = d3.isoParse(xAxis.scale().invert(d3.event.x + 2));
      d3.select(".line").attr("x", d3.event.x);
    },
    redrawTimeSlider() {
      const bodyWidth = document.querySelector("body").clientWidth;
      const timeSelectionWidth = document.querySelector("#timeselection")
        .clientWidth;
      const closeButton = document.querySelector("#closebutton").clientWidth;
      const svgWidth = bodyWidth - timeSelectionWidth - closeButton;
      document
        .querySelector(".sliderContainer svg")
        .setAttribute("width", svgWidth);
      xScale.range([0, svgWidth]);
      xAxis.scale(xScale);
      d3.select(".axis--x").call(xAxis);
      d3.select(".line").attr(
        "x",
        xAxis.scale()(d3.isoParse(this.selectedTime))
      );
      this.rescaleSlider(currentScaleFactor);
    }
  },
  created() {
    this.resizeListenerFunction = debounce(this.redrawTimeSlider, 100);
    window.addEventListener("resize", this.resizeListenerFunction);
  },
  destroyed() {
    window.removeEventListener("resize", this.resizeListenerFunction);
  },
  mounted() {
    setTimeout(this.createSlider, 150);
  }
};
</script>