slick.jsにピンチイン・アウト機能を追加する

## 概要
Swiper.jsでは標準で実装されている[Zoom機能](https://swiperjs.com/demos#zoom)がslick.jsにはない。商品画像のスライド等でよく見るので、実装してみた。

## 条件
 - jquery: ^3.7.1
 - slick-carousel: "^1.8.1

## コード

ピンチイン・アウトできるスライドに`.is-zoomable`を付与しておく。
```html:index.html
<div class="js-slider">
    <div class="is-zoomable">your content</div>
    <div class="is-zoomable">your content</div>
    <div class="is-zoomable">your content</div>
</div>    
```
```javascript:slider.js
cosnt $slider = $('.js-slider');
const option  = { /** オプション */ }

// Initialize Slick slider with options
$slider.slick(option);

$slider.on('touchstart', '.is-zoomable', function (e, slick) {
  onTouchStart(e, slick);
});

$slider.on('touchmove', '.is-zoomable', function (e) {
  onTouchMove(e);
});

$slider.on('touchend', '.is-zoomable', function (e, slick) {
  onTouchEnd(e, slick);
});
```
諸々の初期値
```javascript:slider.js
/** @type {number} The current zoom scale */
let scale = 1;

/** @type {number} The maximum / minimum zoom scale */
let maxScale = 2;
let minScale = 1;

/** @type {number} The starting distance between two touch points for pinch zoom */
let startDistance = 0;

/** @type {number} The current distance between two touch points */
let currentDistance = 0;

/** @type {number} The X/Y coordinates of the center point of pinch zoom (relative to image) */
let originX = 0;
let originY = 0;

/** @type {number} The current horizontal/vertical translation of the image */
let posX = 0;
let posY = 0;

/** @type {number} The previous touch X/Y coordinates for dragging */
let prevX = 0;
let prevY = 0;

/** @type {boolean} Flag to indicate if pinch zoom / drag is in progress */
let isZooming = false;
let isDragging = false;
```
### タッチポイント（指）の間の直線距離の計算
``dx``で二本の指の水平差分（距離の大きさと向き）、``dy``で二本の指の垂直差分を計算する。
```javascript:slider.js
/**
 * Calculates the distance between two touch points on the screen.
 *
 * @param {TouchList} touches - The list of touch points from a touch event
 * @returns {number} The distance between the two touch points in pixels
 */
function calculateDistance(touches) {
    const dx = touches[0].clientX - touches[1].clientX;
    const dy = touches[0].clientY - touches[1].clientY;
    return Math.hypot(dx * dx + dy * dy);
}
```
画像をパン（平行移動）するときに、あらかじめ計算した「許容できる動きの範囲」を超えないように位置を抑え込む。
```javascript
/**
 * Clamps a value within a specified range.
 *
 * @param {number} value - The value to be clamped
 * @param {number} maxDistance - The maximum allowed distance (the range)
 * @returns {number} The clamped value
 */
function limitPosition(value, maxDistance) {
    return Math.min(Math.max(value, -maxDistance), maxDistance);
}

/**
 * Applies the zoom and pan transformations to the image element.
 * @param {HTMLImageElement} img - The image element to apply the transformation to
 */
function updateImageTransform(img) {
    // Calculate the maximum allowable translation based on the image size and scale.
    const maxOffsetX = (img.clientWidth * (scale - 1)) / 2;
    const maxOffsetY = (img.clientHeight * (scale - 1)) / 2;
    
    // Clamp the translation values to ensure the image stays within bounds
    posX = limitPosition(posX, maxOffsetX);
    posY = limitPosition(posY, maxOffsetY);
    
    // When the zoom is reset (scale=1), add an animation to return the image to the center (0,0)
    if (scale === 1) {
      img.style.transition = 'transform 0.3s ease';
      posX = 0;
      posY = 0;
    } else {
      img.style.transition = 'none';
    }
    
    // Apply the transform (translation and scale) to the image
    img.style.transform = `translate(${posX}px, ${posY}px) scale(${scale})`;
    img.style.transformOrigin = `${originX}px ${originY}px`;
    }
```
### Touchstart
ズーム／パン操作のためにスワイプ機能を無効化、``e.touches.length``でピンチ or ドラッグを判定し、初期値・中心点を記録する。
ピンチの中心を、ビューポート座標から画像の矩形 (``getBoundingClientRect``) を引くことで、画像内部のオフセットとして得る。
``touches.length === 2`` でピンチズーム開始処理、``touches.length === 1 && scale > 1`` でズーム中のドラッグパン開始。
```javascript:slider.js
/**
* Handles the touchstart event.
*
* - Initiates pinch zoom with two fingers.
* - Initiates dragging with one finger if zoom is applied.
* @param {TouchEvent} e - The touch event
*/
function onTouchStart(e, slick) {
    const img = e.target;
    
    if (e.touches.length === 2) {
      startDistance = calculateDistance(e.touches);
      // Start pinch zoom
      isZooming = true;
      
      // Calculate the center point of the pinch
      const rect = img.getBoundingClientRect();
      originX = ((e.touches[0].clientX + e.touches[1].clientX) / 2) - rect.left;
      originY = ((e.touches[0].clientY + e.touches[1].clientY) / 2) - rect.top;
      
      // Set the initial position of the pinch
      prevX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
      prevY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
    
      // Disable swipe behavior during pinch zoom
      $(slick.$slider).slick('slickSetOption', 'swipe', false, true);
    } else if (e.touches.length === 1) {
       // Start dragging when zoom is applied
      isDragging = true;

      // Calculate the initial position of the touch
      prevX = e.touches[0].clientX;
      prevY = e.touches[0].clientY;
    }
}
```
### Touchmove
「ズーム中なら倍率更新」「ドラッグ中なら平行移動更新」を行い、毎回``updateImageTransform``で``scale``／``posX``／``posY`` に応じて位置制限（クランプ）と``transform``を再適用する。
#### ピンチズーム
calculateDistance で距離を再取得 → 新旧距離の比率で``scale``に掛ける。距離計測→倍率更新→再計算のループにより連続的にズームする。
#### ドラッグパン
１本指の移動量 (``dx``, ``dy``) を ``posX``, ``posY`` に累積。前回のタッチ座標 (``prevX``, ``prevY``) を更新して差分を常に算出する。
```javascript:slider.js
/**
* Handles the touchmove event.
*
* - Updates zoom scale during pinch zoom.
* - Updates position during drag movement.
* @param {TouchEvent} e - The touch event
*/
function onTouchMove(e) {
    const img = e.target;
    if (isZooming && e.touches.length === 2) {
      // Handle pinch zoom
      currentDistance = calculateDistance(e.touches);
      scale *= currentDistance / startDistance;
      startDistance = currentDistance;
    
      // Clamp the zoom scale between [minScale, maxScale]
      scale = Math.min(Math.max(minScale, scale), maxScale);
    
      updateImageTransform(img);
    } else if (isDragging && e.touches.length === 1 && scale > 1) {
      // Handle dragging (pan)
      const dx = e.touches[0].clientX - prevX;
      const dy = e.touches[0].clientY - prevY;

      // Update the position of the image
      posX += dx;
      posY += dy;

      // Update the previous touch position
      prevX = e.touches[0].clientX;
      prevY = e.touches[0].clientY;
    
      updateImageTransform(img);
    }
}
```
### Touchend
``scale``が「ほぼ元サイズ」だったら厳密に 1 に戻し、``updateImageTransform``内で中央へのアニメーション（transition）が発動するようにする。
ズーム／パン操作のために無効化していたスワイプ機能を、タッチ終了後に再有効化する。
```javascript:slider.js
/**
* Handles the touchend and touchcancel events.
*
* - Finalizes the zoom and drag operations.
* - Resets zoom scale to 1 if the zoom is minimal.
* @param {TouchEvent} e - The touch event
*/
function onTouchEnd(e, slick) {
    const img = e.target;
    isZooming = false;
    isDragging = false;
    
     // If the zoom scale is near 1, reset it to 1
    scale <= 1.05 && (scale = 1);
    
    updateImageTransform(img);
    // Enable swipe behavior again after zooming and panning
    $(slick.$slider).slick('slickSetOption', 'swipe', true, true);
}
```