import { dispatch as d3_dispatch } from 'd3-dispatch';
import { timer as d3_timer } from 'd3-timer';

import {
  select as d3_select
} from 'd3-selection';

import RBush from 'rbush';
import { t } from '../core/localizer';

import {
  geoExtent, geoMetersToLat, geoMetersToLon, geoPointInPolygon,
  geoRotate, geoVecLength
} from '../geo';

import { utilAesDecrypt, utilArrayUnion, utilRebind, utilTiler, utilUniqueDomId } from '../util';

import { services } from './';
import { searchLimited } from '../util/partition';
import { localeTimestamp } from '../util/date';
import { patchHash } from '../behavior';


const streetsideApi = 'https://dev.virtualearth.net/REST/v1/Imagery/MetaData/Streetside?mapArea={bbox}&key={key}&count={count}&uriScheme=https';
const maxResults = 500;
const bubbleAppKey = utilAesDecrypt('5c875730b09c6b422433e807e1ff060b6536c791dbfffcffc4c6b18a1bdba1f14593d151adb50e19e1be1ab19aef813bf135d0f103475e5c724dec94389e45d0');
const pannellumViewerCSS = 'pannellum/pannellum.css';
const pannellumViewerJS = 'pannellum/pannellum.js';
const tileZoom = 16.5;
const tiler = utilTiler().zoomExtent([tileZoom, tileZoom]).skipNullIsland(true);
const dispatch = d3_dispatch('loadedImages', 'viewerChanged');
const minHfov = 10;         // zoom in degrees:  20, 10, 5
const maxHfov = 90;         // zoom out degrees
const defaultHfov = 45;

let _hires = false;
let _resolution = 512;    // higher numbers are slower - 512, 1024, 2048, 4096
let _currScene = 0;
let _ssCache;
let _pannellumViewer;
let _sceneOptions = {
  showFullscreenCtrl: false,
  autoLoad: true,
  compass: true,
  yaw: 0,
  minHfov: minHfov,
  maxHfov: maxHfov,
  hfov: defaultHfov,
  type: 'cubemap',
  cubeMap: []
};
let _loadViewerPromise;


/**
 * abortRequest().
 */
function abortRequest(i) {
  i.abort();
}

/**
 * loadTiles() wraps the process of generating tiles and then fetching image points for each tile.
 */
function loadTiles(which, url, projection, margin) {
  const tiles = tiler.margin(margin).getTiles(projection);

  // abort inflight requests that are no longer needed
  const cache = _ssCache[which];
  Object.keys(cache.inflight).forEach(k => {
    const wanted = tiles.find(tile => k.indexOf(tile.id + ',') === 0);
    if (!wanted) {
      abortRequest(cache.inflight[k]);
      delete cache.inflight[k];
    }
  });

  tiles.forEach(tile => loadNextTilePage(which, url, tile));
}


/**
 * loadNextTilePage() load data for the next tile page in line.
 */
function loadNextTilePage(which, url, tile) {
  const cache = _ssCache[which];
  const nextPage = cache.nextPage[tile.id] || 0;
  const id = tile.id + ',' + String(nextPage);
  if (cache.loaded[id] || cache.inflight[id]) return;

  cache.inflight[id] = getBubbles(url, tile, response => {
    cache.loaded[id] = true;
    delete cache.inflight[id];
    if (!response) return;

    if (response.resourceSets[0].resources.length === maxResults) {
      // there are more bubbles than the response can fit: re-fetch using tile split into 4
      const split = tile.extent.split();
      loadNextTilePage(which, url, { id: tile.id + ',a', extent: split[0] });
      loadNextTilePage(which, url, { id: tile.id + ',b', extent: split[1] });
      loadNextTilePage(which, url, { id: tile.id + ',c', extent: split[2] });
      loadNextTilePage(which, url, { id: tile.id + ',d', extent: split[3] });
    }

    const features = response.resourceSets[0].resources.map(bubble => {
      const bubbleId = bubble.imageUrl;
      if (cache.points[bubbleId]) return null;  // skip duplicates

      // workaround for https://github.com/openstreetmap/iD/issues/10341#issuecomment-2275724738
      const loc = [
        bubble.lon || bubble.longitude,
        bubble.lat || bubble.latitude
      ];
      const d = {
        service: 'photo',
        loc: loc,
        key: bubbleId,
        imageUrl: bubble.imageUrl
          .replace('{subdomain}', bubble.imageUrlSubdomains[0]),
        ca: bubble.he || bubble.heading,
        captured_at: bubble.vintageEnd,
        captured_by: 'microsoft',
        pano: true,
        sequenceKey: null
      };

      cache.points[bubbleId] = d;

      return {
        minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d
      };

    }).filter(Boolean);

    cache.rtree.load(features);

    if (which === 'bubbles') {
      dispatch.call('loadedImages');
    }
  });
}


/**
 * getBubbles() handles the request to the server for a tile extent of 'bubbles' (streetside image locations).
 */
function getBubbles(url, tile, callback) {
  let rect = tile.extent.rectangle();
    const controller = new AbortController();

    bubbleAppKey
      .then(key => url
        .replace('{key}', key)
        .replace('{bbox}', [rect[1], rect[0], rect[3], rect[2]].join(','))
        .replace('{count}', maxResults)
      )
      .then(url => fetch(url, { signal: controller.signal }))
      .then(function(response) {
        if (!response.ok) {
          throw new Error(response.status + ' ' + response.statusText);
        }
        return response.json();
      }).then(function(result) {
        if (!result) {
          callback(null);
        }
        return callback(result || []);
      }).catch(function(err) {
        if (err.name === 'AbortError') {
        // ignore aborted requests, e.g. from duplicate requests while zooming/panning the map
        } else {
          throw new Error(err);
        }
      });
    return controller;
  }


/**
 * loadImage()
 */
function loadImage(imgInfo) {
  return new Promise(resolve => {
    let img = new Image();
    img.onload = () => {
      let canvas = document.getElementById('ideditor-canvas' + imgInfo.face);
      let ctx = canvas.getContext('2d');
      ctx.drawImage(img, imgInfo.x, imgInfo.y);
      resolve({ imgInfo: imgInfo, status: 'ok' });
    };
    img.onerror = () => {
      resolve({ data: imgInfo, status: 'error' });
    };
    img.setAttribute('crossorigin', '');
    img.src = imgInfo.url;
  });
}


/**
 * loadCanvas()
 */
function loadCanvas(imageGroup) {
  return Promise.all(imageGroup.map(loadImage))
    .then((data) => {
      let canvas = document.getElementById('ideditor-canvas' + data[0].imgInfo.face);
      const which = { '01': 0, '02': 1, '03': 2, '10': 3, '11': 4, '12': 5 };
      let face = data[0].imgInfo.face;
      _sceneOptions.cubeMap[which[face]] = canvas.toDataURL('image/jpeg', 1.0);
      return { status: 'loadCanvas for face ' + data[0].imgInfo.face + 'ok'};
    });
}


/**
 * loadFaces()
 */
function loadFaces(faceGroup) {
  return Promise.all(faceGroup.map(loadCanvas))
    .then(() => { return { status: 'loadFaces done' }; });
}


function setupCanvas(selection, reset) {
  if (reset) {
    selection.selectAll('#ideditor-stitcher-canvases')
      .remove();
  }

  // Add the Streetside working canvases. These are used for 'stitching', or combining,
  // multiple images for each of the six faces, before passing to the Pannellum control as DataUrls
  selection.selectAll('#ideditor-stitcher-canvases')
    .data([0])
    .enter()
    .append('div')
    .attr('id', 'ideditor-stitcher-canvases')
    .attr('display', 'none')
    .selectAll('canvas')
    .data(['canvas01', 'canvas02', 'canvas03', 'canvas10', 'canvas11', 'canvas12'])
    .enter()
    .append('canvas')
    .attr('id', d => 'ideditor-' + d)
    .attr('width', _resolution)
    .attr('height', _resolution);
}


function qkToXY(qk) {
  let x = 0;
  let y = 0;
  let scale = 256;
  for (let i = qk.length; i > 0; i--) {
    const key = qk[i-1];
    x += (+(key === '1' || key === '3')) * scale;
    y += (+(key === '2' || key === '3')) * scale;
    scale *= 2;
  }
  return [x, y];
}


function getQuadKeys() {
  let dim = _resolution / 256;
  let quadKeys;

  if (dim === 16) {
    quadKeys = [
      '0000','0001','0010','0011','0100','0101','0110','0111',  '1000','1001','1010','1011','1100','1101','1110','1111',
      '0002','0003','0012','0013','0102','0103','0112','0113',  '1002','1003','1012','1013','1102','1103','1112','1113',
      '0020','0021','0030','0031','0120','0121','0130','0131',  '1020','1021','1030','1031','1120','1121','1130','1131',
      '0022','0023','0032','0033','0122','0123','0132','0133',  '1022','1023','1032','1033','1122','1123','1132','1133',
      '0200','0201','0210','0211','0300','0301','0310','0311',  '1200','1201','1210','1211','1300','1301','1310','1311',
      '0202','0203','0212','0213','0302','0303','0312','0313',  '1202','1203','1212','1213','1302','1303','1312','1313',
      '0220','0221','0230','0231','0320','0321','0330','0331',  '1220','1221','1230','1231','1320','1321','1330','1331',
      '0222','0223','0232','0233','0322','0323','0332','0333',  '1222','1223','1232','1233','1322','1323','1332','1333',

      '2000','2001','2010','2011','2100','2101','2110','2111',  '3000','3001','3010','3011','3100','3101','3110','3111',
      '2002','2003','2012','2013','2102','2103','2112','2113',  '3002','3003','3012','3013','3102','3103','3112','3113',
      '2020','2021','2030','2031','2120','2121','2130','2131',  '3020','3021','3030','3031','3120','3121','3130','3131',
      '2022','2023','2032','2033','2122','2123','2132','2133',  '3022','3023','3032','3033','3122','3123','3132','3133',
      '2200','2201','2210','2211','2300','2301','2310','2311',  '3200','3201','3210','3211','3300','3301','3310','3311',
      '2202','2203','2212','2213','2302','2303','2312','2313',  '3202','3203','3212','3213','3302','3303','3312','3313',
      '2220','2221','2230','2231','2320','2321','2330','2331',  '3220','3221','3230','3231','3320','3321','3330','3331',
      '2222','2223','2232','2233','2322','2323','2332','2333',  '3222','3223','3232','3233','3322','3323','3332','3333'
    ];

  } else if (dim === 8) {
    quadKeys = [
      '000','001','010','011',  '100','101','110','111',
      '002','003','012','013',  '102','103','112','113',
      '020','021','030','031',  '120','121','130','131',
      '022','023','032','033',  '122','123','132','133',

      '200','201','210','211',  '300','301','310','311',
      '202','203','212','213',  '302','303','312','313',
      '220','221','230','231',  '320','321','330','331',
      '222','223','232','233',  '322','323','332','333'
    ];

  } else if (dim === 4) {
    quadKeys = [
      '00','01',  '10','11',
      '02','03',  '12','13',

      '20','21',  '30','31',
      '22','23',  '32','33'
    ];

  } else {  // dim === 2
    quadKeys = [
      '0', '1',
      '2', '3'
    ];
  }

  return quadKeys;
}



export default {
  /**
   * init() initialize streetside.
   */
  init: function() {
    if (!_ssCache) {
      this.reset();
    }

    this.event = utilRebind(this, dispatch, 'on');
  },

  /**
   * reset() reset the cache.
   */
  reset: function() {
    if (_ssCache) {
      Object.values(_ssCache.bubbles.inflight).forEach(abortRequest);
    }

    _ssCache = {
      bubbles: { inflight: {}, loaded: {}, nextPage: {}, rtree: new RBush(), points: {} },
      sequences: {}
    };
  },

  /**
   * bubbles()
   */
  bubbles: function(projection) {
    const limit = 5;
    return searchLimited(limit, projection, _ssCache.bubbles.rtree);
  },


  cachedImage: function(imageKey) {
      return _ssCache.bubbles.points[imageKey];
  },


  sequences: function(projection) {
    const viewport = projection.clipExtent();
    const min = [viewport[0][0], viewport[1][1]];
    const max = [viewport[1][0], viewport[0][1]];
    const bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox();
    let seen = {};
    let results = [];

    // all sequences for bubbles in viewport
    _ssCache.bubbles.rtree.search(bbox)
      .forEach(d => {
        const key = d.data.sequenceKey;
        if (key && !seen[key]) {
            seen[key] = true;
            results.push(_ssCache.sequences[key].geojson);
        }
      });

    return results;
  },


  /**
   * loadBubbles()
   */
  loadBubbles: function(projection, margin) {
    // by default: request 2 nearby tiles so we can connect sequences.
    if (margin === undefined) margin = 2;

    loadTiles('bubbles', streetsideApi, projection, margin);
  },


  viewer: function() {
    return _pannellumViewer;
  },


  initViewer: function () {
    if (!window.pannellum) return;
    if (_pannellumViewer) return;

    _currScene += 1;
    const sceneID = _currScene.toString();
    const options = {
      'default': { firstScene: sceneID },
      scenes: {}
    };
    options.scenes[sceneID] = _sceneOptions;

    _pannellumViewer = window.pannellum.viewer('ideditor-viewer-streetside', options);
  },


  ensureViewerLoaded: function(context) {

    if (_loadViewerPromise) return _loadViewerPromise;

    // create ms-wrapper, a photo wrapper class
    let wrap = context.container().select('.photoviewer').selectAll('.ms-wrapper')
      .data([0]);

    // inject ms-wrapper into the photoviewer div
    // (used by all to house each custom photo viewer)
    let wrapEnter = wrap.enter()
      .append('div')
      .attr('class', 'photo-wrapper ms-wrapper')
      .classed('hide', true);

    let that = this;

    let pointerPrefix = 'PointerEvent' in window ? 'pointer' : 'mouse';

    // inject div to support streetside viewer (pannellum) and attribution line
    wrapEnter
      .append('div')
      .attr('id', 'ideditor-viewer-streetside')
      .on(pointerPrefix + 'down.streetside', () => {
        d3_select(window)
          .on(pointerPrefix + 'move.streetside', () => {
            dispatch.call('viewerChanged');
          }, true);
      })
      .on(pointerPrefix + 'up.streetside pointercancel.streetside', () => {
        d3_select(window)
          .on(pointerPrefix + 'move.streetside', null);

        // continue dispatching events for a few seconds, in case viewer has inertia.
        let t = d3_timer(elapsed => {
          dispatch.call('viewerChanged');
          if (elapsed > 2000) {
            t.stop();
          }
        });
      })
      .append('div')
      .attr('class', 'photo-attribution fillD');

    let controlsEnter = wrapEnter
      .append('div')
      .attr('class', 'photo-controls-wrap')
      .append('div')
      .attr('class', 'photo-controls');

    controlsEnter
      .append('button')
      .on('click.back', step(-1))
      .text('◄');

    controlsEnter
      .append('button')
      .on('click.forward', step(1))
      .text('►');


    // create working canvas for stitching together images
    wrap
      .merge(wrapEnter)
      .call(setupCanvas, true);

    // Register viewer resize handler
    context.ui().photoviewer.on('resize.streetside', () => {
      if (_pannellumViewer) {
        _pannellumViewer.resize();
      }
    });

    _loadViewerPromise = new Promise((resolve, reject) => {

      let loadedCount = 0;
      function loaded() {
        loadedCount += 1;
        // wait until both files are loaded
        if (loadedCount === 2) resolve();
      }

      const head = d3_select('head');

      // load streetside pannellum viewer css
      head.selectAll('#ideditor-streetside-viewercss')
        .data([0])
        .enter()
        .append('link')
        .attr('id', 'ideditor-streetside-viewercss')
        .attr('rel', 'stylesheet')
        .attr('crossorigin', 'anonymous')
        .attr('href', context.asset(pannellumViewerCSS))
        .on('load.serviceStreetside', loaded)
        .on('error.serviceStreetside', function() {
            reject();
        });

      // load streetside pannellum viewer js
      head.selectAll('#ideditor-streetside-viewerjs')
        .data([0])
        .enter()
        .append('script')
        .attr('id', 'ideditor-streetside-viewerjs')
        .attr('crossorigin', 'anonymous')
        .attr('src', context.asset(pannellumViewerJS))
        .on('load.serviceStreetside', loaded)
        .on('error.serviceStreetside', function() {
            reject();
        });
      })
      .catch(function() {
        _loadViewerPromise = null;
      });

    return _loadViewerPromise;

    function step(stepBy) {
      return () => {
        let viewer = context.container().select('.photoviewer');
        let selected = viewer.empty() ? undefined : viewer.datum();
        if (!selected) return;

        let nextID = (stepBy === 1 ? selected.ne : selected.pr);
        let yaw = _pannellumViewer.getYaw();
        let ca = selected.ca + yaw;
        let origin = selected.loc;

        // construct a search trapezoid pointing out from current bubble
        const meters = 35;
        let p1 = [
          origin[0] + geoMetersToLon(meters / 5, origin[1]),
          origin[1]
        ];
        let p2 = [
          origin[0] + geoMetersToLon(meters / 2, origin[1]),
          origin[1] + geoMetersToLat(meters)
        ];
        let p3 = [
          origin[0] - geoMetersToLon(meters / 2, origin[1]),
          origin[1] + geoMetersToLat(meters)
        ];
        let p4 = [
          origin[0] - geoMetersToLon(meters / 5, origin[1]),
          origin[1]
        ];

        let poly = [p1, p2, p3, p4, p1];

        // rotate it to face forward/backward
        let angle = (stepBy === 1 ? ca : ca + 180) * (Math.PI / 180);
        poly = geoRotate(poly, -angle, origin);

        let extent = poly.reduce((extent, point) => {
          return extent.extend(geoExtent(point));
        }, geoExtent());

        // find nearest other bubble in the search polygon
        let minDist = Infinity;
        _ssCache.bubbles.rtree.search(extent.bbox())
          .forEach(d => {
            if (d.data.key === selected.key) return;
            if (!geoPointInPolygon(d.data.loc, poly)) return;

            let dist = geoVecLength(d.data.loc, selected.loc);
            let theta = selected.ca - d.data.ca;
            let minTheta = Math.min(Math.abs(theta), 360 - Math.abs(theta));
            if (minTheta > 20) {
              dist += 5;  // penalize distance if camera angles don't match
            }

            if (dist < minDist) {
              nextID = d.data.key;
              minDist = dist;
            }
          });

        let nextBubble = nextID && that.cachedImage(nextID);
        if (!nextBubble) return;

        context.map().centerEase(nextBubble.loc);

        that.selectImage(context, nextBubble.key)
          .yaw(yaw)
          .showViewer(context);
      };
    }
  },


  yaw: function(yaw) {
    if (typeof yaw !== 'number') return yaw;
    _sceneOptions.yaw = yaw;
    return this;
  },

  /**
   * showViewer()
   */
  showViewer: function(context) {
    const wrap = context.container().select('.photoviewer');
    const isHidden = wrap.selectAll('.photo-wrapper.ms-wrapper.hide').size();

    if (isHidden) {
      for (const service of Object.values(services)) {
        if (service === this) continue;
        if (typeof service.hideViewer === 'function') {
          service.hideViewer(context);
        }
      }
      wrap
        .classed('hide', false)
        .selectAll('.photo-wrapper.ms-wrapper')
        .classed('hide', false);
    }

    return this;
  },


  /**
   * hideViewer()
   */
  hideViewer: function (context) {
    let viewer = context.container().select('.photoviewer');
    if (!viewer.empty()) viewer.datum(null);

    viewer
      .classed('hide', true)
      .selectAll('.photo-wrapper')
      .classed('hide', true);

    context.container().selectAll('.viewfield-group, .sequence, .icon-sign')
      .classed('currentView', false);

    patchHash({ photo: null });

    return this.setStyles(context, null, true);
  },


  /**
   * selectImage().
   */
  selectImage: function (context, key) {
    let that = this;

    let d = this.cachedImage(key);

    let viewer = context.container().select('.photoviewer');
    if (!viewer.empty()) viewer.datum(d);

    this.setStyles(context, null, true);

    let wrap = context.container().select('.photoviewer .ms-wrapper');
    let attribution = wrap.selectAll('.photo-attribution').html('');

    wrap.selectAll('.pnlm-load-box')   // display "loading.."
      .style('display', 'block');

    if (!d) return this;

    patchHash({ photo: 'streetside/' + key });

    _sceneOptions.northOffset = d.ca;

    let line1 = attribution
      .append('div')
      .attr('class', 'attribution-row');

    const hiresDomId = utilUniqueDomId('streetside-hires');

    // Add hires checkbox
    let label = line1
      .append('label')
      .attr('for', hiresDomId)
      .attr('class', 'streetside-hires');

    label
      .append('input')
      .attr('type', 'checkbox')
      .attr('id', hiresDomId)
      .property('checked', _hires)
      .on('click', (d3_event) => {
        d3_event.stopPropagation();

        _hires = !_hires;
        _resolution = _hires ? 1024 : 512;
        wrap.call(setupCanvas, true);

        let viewstate = {
          yaw: _pannellumViewer.getYaw(),
          pitch: _pannellumViewer.getPitch(),
          hfov: _pannellumViewer.getHfov()
        };

        _sceneOptions = Object.assign(_sceneOptions, viewstate);
        that.selectImage(context, d.key)
          .showViewer(context);
      });

    label
      .append('span')
      .call(t.append('streetside.hires'));


    let captureInfo = line1
      .append('div')
      .attr('class', 'attribution-capture-info');

    // Add capture date
    if (d.captured_by) {
      const yyyy = (new Date()).getFullYear();

      captureInfo
        .append('a')
        .attr('class', 'captured_by')
        .attr('target', '_blank')
        .attr('href', 'https://www.microsoft.com/en-us/maps/streetside')
        .text('©' + yyyy + ' Microsoft');

      captureInfo
        .append('span')
        .text('|');
    }

    if (d.captured_at) {
      captureInfo
        .append('span')
        .attr('class', 'captured_at')
        .text(localeTimestamp(d.captured_at));
    }

    // Add image links
    let line2 = attribution
      .append('div')
      .attr('class', 'attribution-row');

    line2
      .append('a')
      .attr('class', 'image-report-link')
      .attr('target', '_blank')
      .attr('href', 'https://www.bing.com/maps/privacyreport/streetsideprivacyreport?bubbleid=' +
        encodeURIComponent(d.key) + '&focus=photo&lat=' + d.loc[1] + '&lng=' + d.loc[0] + '&z=17')
      .call(t.append('streetside.report'));

    // Cubemap face code order matters here: front=01, right=02, back=03, left=10, up=11, down=12
    const faceKeys = ['01','02','03','10','11','12'];

    // Map images to cube faces
    let quadKeys = getQuadKeys();
    let faces = faceKeys.map((faceKey) => {
      return quadKeys.map((quadKey) => {
        const xy = qkToXY(quadKey);
        return {
          face: faceKey,
          url: d.imageUrl
            .replace('{faceId}', faceKey)
            .replace('{tileId}', quadKey),
          x: xy[0],
          y: xy[1]
        };
      });
    });

    loadFaces(faces)
      .then(function() {

        if (!_pannellumViewer) {
          that.initViewer();
        } else {
          // make a new scene
          _currScene += 1;
          let sceneID = _currScene.toString();
          _pannellumViewer
            .addScene(sceneID, _sceneOptions)
            .loadScene(sceneID);

          // remove previous scene
          if (_currScene > 2) {
            sceneID = (_currScene - 1).toString();
            _pannellumViewer
              .removeScene(sceneID);
          }
        }
      });

    return this;
  },


  getSequenceKeyForBubble: function(d) {
    return d && d.sequenceKey;
  },


  // Updates the currently highlighted sequence and selected bubble.
  // Reset is only necessary when interacting with the viewport because
  // this implicitly changes the currently selected bubble/sequence
  setStyles: function (context, hovered, reset) {
    if (reset) {  // reset all layers
      context.container().selectAll('.viewfield-group')
        .classed('highlighted', false)
        .classed('hovered', false)
        .classed('currentView', false);

      context.container().selectAll('.sequence')
        .classed('highlighted', false)
        .classed('currentView', false);
    }

    let hoveredBubbleKey = hovered && hovered.key;
    let hoveredSequenceKey = this.getSequenceKeyForBubble(hovered);
    let hoveredSequence = hoveredSequenceKey && _ssCache.sequences[hoveredSequenceKey];
    let hoveredBubbleKeys =  (hoveredSequence && hoveredSequence.bubbles.map(d => d.key)) || [];

    let viewer = context.container().select('.photoviewer');
    let selected = viewer.empty() ? undefined : viewer.datum();
    let selectedBubbleKey = selected && selected.key;
    let selectedSequenceKey = this.getSequenceKeyForBubble(selected);
    let selectedSequence = selectedSequenceKey && _ssCache.sequences[selectedSequenceKey];
    let selectedBubbleKeys = (selectedSequence && selectedSequence.bubbles.map(d => d.key)) || [];

    // highlight sibling viewfields on either the selected or the hovered sequences
    let highlightedBubbleKeys = utilArrayUnion(hoveredBubbleKeys, selectedBubbleKeys);

    context.container().selectAll('.layer-streetside-images .viewfield-group')
      .classed('highlighted', d => highlightedBubbleKeys.indexOf(d.key) !== -1)
      .classed('hovered',     d => d.key === hoveredBubbleKey)
      .classed('currentView', d => d.key === selectedBubbleKey);

    context.container().selectAll('.layer-streetside-images .sequence')
      .classed('highlighted', d => d.properties.key === hoveredSequenceKey)
      .classed('currentView', d => d.properties.key === selectedSequenceKey);

    // update viewfields if needed
    context.container().selectAll('.layer-streetside-images .viewfield-group .viewfield')
      .attr('d', viewfieldPath);

    function viewfieldPath() {
      let d = this.parentNode.__data__;
      if (d.pano && d.key !== selectedBubbleKey) {
        return 'M 8,13 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0';
      } else {
        return 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z';
      }
    }

    return this;
  },


  /**
   * cache().
   */
  cache: function () {
    return _ssCache;
  }
};
