1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5'use strict';
6
7/**
8 * Viewport class controls the way the image is displayed (scale, offset etc).
9 * @constructor
10 */
11function Viewport() {
12  this.imageBounds_ = new Rect();
13  this.screenBounds_ = new Rect();
14
15  this.scale_ = 1;
16  this.offsetX_ = 0;
17  this.offsetY_ = 0;
18
19  this.generation_ = 0;
20
21  this.scaleControl_ = null;
22  this.repaintCallbacks_ = [];
23  this.update();
24}
25
26/*
27 * Viewport modification.
28 */
29
30/**
31 * @param {Object} scaleControl The UI object responsible for scaling.
32 */
33Viewport.prototype.setScaleControl = function(scaleControl) {
34  this.scaleControl_ = scaleControl;
35};
36
37/**
38 * @param {number} width Image width.
39 * @param {number} height Image height.
40 */
41Viewport.prototype.setImageSize = function(width, height) {
42  this.imageBounds_ = new Rect(width, height);
43  if (this.scaleControl_) this.scaleControl_.displayImageSize(width, height);
44  this.invalidateCaches();
45};
46
47/**
48 * @param {number} width Screen width.
49 * @param {number} height Screen height.
50 */
51Viewport.prototype.setScreenSize = function(width, height) {
52  this.screenBounds_ = new Rect(width, height);
53  if (this.scaleControl_)
54    this.scaleControl_.setMinScale(this.getFittingScale());
55  this.invalidateCaches();
56};
57
58/**
59 * Set the size by an HTML element.
60 *
61 * @param {HTMLElement} frame The element acting as the "screen".
62 */
63Viewport.prototype.sizeByFrame = function(frame) {
64  this.setScreenSize(frame.clientWidth, frame.clientHeight);
65};
66
67/**
68 * Set the size and scale to fit an HTML element.
69 *
70 * @param {HTMLElement} frame The element acting as the "screen".
71 */
72Viewport.prototype.sizeByFrameAndFit = function(frame) {
73  var wasFitting = this.getScale() == this.getFittingScale();
74  this.sizeByFrame(frame);
75  var minScale = this.getFittingScale();
76  if (wasFitting || (this.getScale() < minScale)) {
77    this.setScale(minScale, true);
78  }
79};
80
81/**
82 * @return {number} Scale.
83 */
84Viewport.prototype.getScale = function() { return this.scale_ };
85
86/**
87 * @param {number} scale The new scale.
88 * @param {boolean} notify True if the change should be reflected in the UI.
89 */
90Viewport.prototype.setScale = function(scale, notify) {
91  if (this.scale_ == scale) return;
92  this.scale_ = scale;
93  if (notify && this.scaleControl_) this.scaleControl_.displayScale(scale);
94  this.invalidateCaches();
95};
96
97/**
98 * @return {number} Best scale to fit the current image into the current screen.
99 */
100Viewport.prototype.getFittingScale = function() {
101  var scaleX = this.screenBounds_.width / this.imageBounds_.width;
102  var scaleY = this.screenBounds_.height / this.imageBounds_.height;
103  // Scales > (1 / this.getDevicePixelRatio()) do not look good. Also they are
104  // not really useful as we do not have any pixel-level operations.
105  return Math.min(1 / Viewport.getDevicePixelRatio(), scaleX, scaleY);
106};
107
108/**
109 * Set the scale to fit the image into the screen.
110 */
111Viewport.prototype.fitImage = function() {
112  var scale = this.getFittingScale();
113  if (this.scaleControl_) this.scaleControl_.setMinScale(scale);
114  this.setScale(scale, true);
115};
116
117/**
118 * @return {number} X-offset of the viewport.
119 */
120Viewport.prototype.getOffsetX = function() { return this.offsetX_ };
121
122/**
123 * @return {number} Y-offset of the viewport.
124 */
125Viewport.prototype.getOffsetY = function() { return this.offsetY_ };
126
127/**
128 * Set the image offset in the viewport.
129 * @param {number} x X-offset.
130 * @param {number} y Y-offset.
131 * @param {boolean} ignoreClipping True if no clipping should be applied.
132 */
133Viewport.prototype.setOffset = function(x, y, ignoreClipping) {
134  if (!ignoreClipping) {
135    x = this.clampOffsetX_(x);
136    y = this.clampOffsetY_(y);
137  }
138  if (this.offsetX_ == x && this.offsetY_ == y) return;
139  this.offsetX_ = x;
140  this.offsetY_ = y;
141  this.invalidateCaches();
142};
143
144/**
145 * Return a closure that can be called to pan the image.
146 * Useful for implementing non-trivial variants of panning (overview etc).
147 * @param {number} originalX The x coordinate on the screen canvas that
148 *                 corresponds to zero change to offsetX.
149 * @param {number} originalY The y coordinate on the screen canvas that
150 *                 corresponds to zero change to offsetY.
151 * @param {function():number} scaleFunc returns the image to screen scale.
152 * @param {function(number,number):boolean} hitFunc returns true if (x,y) is
153 *                                                  in the valid region.
154 * @return {function} The closure to pan the image.
155 */
156Viewport.prototype.createOffsetSetter = function(
157    originalX, originalY, scaleFunc, hitFunc) {
158  var originalOffsetX = this.offsetX_;
159  var originalOffsetY = this.offsetY_;
160  if (!hitFunc) hitFunc = function() { return true };
161  if (!scaleFunc) scaleFunc = this.getScale.bind(this);
162
163  var self = this;
164  return function(x, y) {
165    if (hitFunc(x, y)) {
166      var scale = scaleFunc();
167      self.setOffset(
168          originalOffsetX + (x - originalX) / scale,
169          originalOffsetY + (y - originalY) / scale);
170      self.repaint();
171    }
172  };
173};
174
175/*
176 * Access to the current viewport state.
177 */
178
179/**
180 * @return {Rect} The image bounds in image coordinates.
181 */
182Viewport.prototype.getImageBounds = function() { return this.imageBounds_ };
183
184/**
185* @return {Rect} The screen bounds in screen coordinates.
186*/
187Viewport.prototype.getScreenBounds = function() { return this.screenBounds_ };
188
189/**
190 * @return {Rect} The visible part of the image, in image coordinates.
191 */
192Viewport.prototype.getImageClipped = function() { return this.imageClipped_ };
193
194/**
195 * @return {Rect} The visible part of the image, in screen coordinates.
196 */
197Viewport.prototype.getScreenClipped = function() { return this.screenClipped_ };
198
199/**
200 * A counter that is incremented with each viewport state change.
201 * Clients that cache anything that depends on the viewport state should keep
202 * track of this counter.
203 * @return {number} counter.
204 */
205Viewport.prototype.getCacheGeneration = function() { return this.generation_ };
206
207/**
208 * Called on event view port state change (even if repaint has not been called).
209 */
210Viewport.prototype.invalidateCaches = function() { this.generation_++ };
211
212/**
213 * @return {Rect} The image bounds in screen coordinates.
214 */
215Viewport.prototype.getImageBoundsOnScreen = function() {
216  return this.imageOnScreen_;
217};
218
219/*
220 * Conversion between the screen and image coordinate spaces.
221 */
222
223/**
224 * @param {number} size Size in screen coordinates.
225 * @return {number} Size in image coordinates.
226 */
227Viewport.prototype.screenToImageSize = function(size) {
228  return size / this.getScale();
229};
230
231/**
232 * @param {number} x X in screen coordinates.
233 * @return {number} X in image coordinates.
234 */
235Viewport.prototype.screenToImageX = function(x) {
236  return Math.round((x - this.imageOnScreen_.left) / this.getScale());
237};
238
239/**
240 * @param {number} y Y in screen coordinates.
241 * @return {number} Y in image coordinates.
242 */
243Viewport.prototype.screenToImageY = function(y) {
244  return Math.round((y - this.imageOnScreen_.top) / this.getScale());
245};
246
247/**
248 * @param {Rect} rect Rectangle in screen coordinates.
249 * @return {Rect} Rectangle in image coordinates.
250 */
251Viewport.prototype.screenToImageRect = function(rect) {
252  return new Rect(
253      this.screenToImageX(rect.left),
254      this.screenToImageY(rect.top),
255      this.screenToImageSize(rect.width),
256      this.screenToImageSize(rect.height));
257};
258
259/**
260 * @param {number} size Size in image coordinates.
261 * @return {number} Size in screen coordinates.
262 */
263Viewport.prototype.imageToScreenSize = function(size) {
264  return size * this.getScale();
265};
266
267/**
268 * @param {number} x X in image coordinates.
269 * @return {number} X in screen coordinates.
270 */
271Viewport.prototype.imageToScreenX = function(x) {
272  return Math.round(this.imageOnScreen_.left + x * this.getScale());
273};
274
275/**
276 * @param {number} y Y in image coordinates.
277 * @return {number} Y in screen coordinates.
278 */
279Viewport.prototype.imageToScreenY = function(y) {
280  return Math.round(this.imageOnScreen_.top + y * this.getScale());
281};
282
283/**
284 * @param {Rect} rect Rectangle in image coordinates.
285 * @return {Rect} Rectangle in screen coordinates.
286 */
287Viewport.prototype.imageToScreenRect = function(rect) {
288  return new Rect(
289      this.imageToScreenX(rect.left),
290      this.imageToScreenY(rect.top),
291      Math.round(this.imageToScreenSize(rect.width)),
292      Math.round(this.imageToScreenSize(rect.height)));
293};
294
295/**
296 * @return {number} The number of physical pixels in one CSS pixel.
297 */
298Viewport.getDevicePixelRatio = function() { return window.devicePixelRatio };
299
300/**
301 * Convert a rectangle from screen coordinates to 'device' coordinates.
302 *
303 * This conversion enlarges the original rectangle devicePixelRatio times
304 * with the screen center as a fixed point.
305 *
306 * @param {Rect} rect Rectangle in screen coordinates.
307 * @return {Rect} Rectangle in device coordinates.
308 */
309Viewport.prototype.screenToDeviceRect = function(rect) {
310  var ratio = Viewport.getDevicePixelRatio();
311  var screenCenterX = Math.round(
312      this.screenBounds_.left + this.screenBounds_.width / 2);
313  var screenCenterY = Math.round(
314      this.screenBounds_.top + this.screenBounds_.height / 2);
315  return new Rect(screenCenterX + (rect.left - screenCenterX) * ratio,
316                  screenCenterY + (rect.top - screenCenterY) * ratio,
317                  rect.width * ratio,
318                  rect.height * ratio);
319};
320
321/**
322 * @return {Rect} The visible part of the image, in device coordinates.
323 */
324Viewport.prototype.getDeviceClipped = function() {
325  return this.screenToDeviceRect(this.getScreenClipped());
326};
327
328/**
329 * @return {boolean} True if some part of the image is clipped by the screen.
330 */
331Viewport.prototype.isClipped = function() {
332  return this.getMarginX_() < 0 || this.getMarginY_() < 0;
333};
334
335/**
336 * @return {number} Horizontal margin.
337 *   Negative if the image is clipped horizontally.
338 * @private
339 */
340Viewport.prototype.getMarginX_ = function() {
341  return Math.round(
342    (this.screenBounds_.width - this.imageBounds_.width * this.scale_) / 2);
343};
344
345/**
346 * @return {number} Vertical margin.
347 *   Negative if the image is clipped vertically.
348 * @private
349 */
350Viewport.prototype.getMarginY_ = function() {
351  return Math.round(
352    (this.screenBounds_.height - this.imageBounds_.height * this.scale_) / 2);
353};
354
355/**
356 * @param {number} x X-offset.
357 * @return {number} X-offset clamped to the valid range.
358 * @private
359 */
360Viewport.prototype.clampOffsetX_ = function(x) {
361  var limit = Math.round(Math.max(0, -this.getMarginX_() / this.getScale()));
362  return ImageUtil.clamp(-limit, x, limit);
363};
364
365/**
366 * @param {number} y Y-offset.
367 * @return {number} Y-offset clamped to the valid range.
368 * @private
369 */
370Viewport.prototype.clampOffsetY_ = function(y) {
371  var limit = Math.round(Math.max(0, -this.getMarginY_() / this.getScale()));
372  return ImageUtil.clamp(-limit, y, limit);
373};
374
375/**
376 * Recalculate the viewport parameters.
377 */
378Viewport.prototype.update = function() {
379  var scale = this.getScale();
380
381  // Image bounds in screen coordinates.
382  this.imageOnScreen_ = new Rect(
383      this.getMarginX_(),
384      this.getMarginY_(),
385      Math.round(this.imageBounds_.width * scale),
386      Math.round(this.imageBounds_.height * scale));
387
388  // A visible part of the image in image coordinates.
389  this.imageClipped_ = new Rect(this.imageBounds_);
390
391  // A visible part of the image in screen coordinates.
392  this.screenClipped_ = new Rect(this.screenBounds_);
393
394  // Adjust for the offset.
395  if (this.imageOnScreen_.left < 0) {
396    this.imageOnScreen_.left +=
397        Math.round(this.clampOffsetX_(this.offsetX_) * scale);
398    this.imageClipped_.left = Math.round(-this.imageOnScreen_.left / scale);
399    this.imageClipped_.width = Math.round(this.screenBounds_.width / scale);
400  } else {
401    this.screenClipped_.left = this.imageOnScreen_.left;
402    this.screenClipped_.width = this.imageOnScreen_.width;
403  }
404
405  if (this.imageOnScreen_.top < 0) {
406    this.imageOnScreen_.top +=
407        Math.round(this.clampOffsetY_(this.offsetY_) * scale);
408    this.imageClipped_.top = Math.round(-this.imageOnScreen_.top / scale);
409    this.imageClipped_.height = Math.round(this.screenBounds_.height / scale);
410  } else {
411    this.screenClipped_.top = this.imageOnScreen_.top;
412    this.screenClipped_.height = this.imageOnScreen_.height;
413  }
414};
415
416/**
417 * @param {function} callback Repaint callback.
418 */
419Viewport.prototype.addRepaintCallback = function(callback) {
420  this.repaintCallbacks_.push(callback);
421};
422
423/**
424 * Repaint all clients.
425 */
426Viewport.prototype.repaint = function() {
427  this.update();
428  for (var i = 0; i != this.repaintCallbacks_.length; i++)
429    this.repaintCallbacks_[i]();
430};
431