View on GitHub

アイテム詳細ページの作成

Home

アイテム詳細ページの作成

推薦スロット上でアイテムがクリックされたときに、そのアイテムの詳細ページを表示するようにしましょう。recsys_django/static/js/ディレクトリにDetailPage.jsItemDetailComponent.jsStar.jsStarRating.jsReturnButton.jsを作成し、それぞれ下記のコードを記述してください。

リスト1: recsys_django/static/js/DetailPage.js

/**
 * 詳細ページクラス
 */
class DetailPage extends Page {
    /*
     * @override
     */
    constructor(canvas, context) {
        super(canvas, context);

        this.itemDetailComponent = null;   // アイテム詳細コンポーネント

        // 推薦スロット
        this.slot = new Slot(DETAIL_SLOT_LEFT, DETAIL_SLOT_TOP, SLOT_WIDTH, SLOT_HEIGHT, SLOT_SIZE);

        // 「戻る」ボタン
        this.returnButton = new ReturnButton(DETAIL_RETURN_LEFT, DETAIL_RETURN_TOP, DETAIL_RETURN_WIDTH, DETAIL_RETURN_WIDTH);
    }
    /*
     * @override
     */
    onClick(x, y) {
        // アイテム詳細コンポーネント内の星クリックの判定
        let rating = this.itemDetailComponent.starRating.getRatingOn(x, y);
        if (rating > 0) {
            this.postRating(rating);
            return;
        }

        // 推薦スロット内のボタンクリックの判定
        if (this.slot.prevButton.isWithin(x, y)) {
            // 「前へ」ボタン
            this.slot.prev();
            return;
        } else if (this.slot.nextButton.isWithin(x, y)) {
            // 「次へ」ボタン
            this.slot.next();
            return;
        }

        // 推薦スロット内のアイテムクリックの判定
        let itemComponent = this.slot.getItemOn(x, y);
        if (itemComponent != null) {
            this.open(itemComponent.item);
            return;
        }

        // 「戻る」ボタンクリックの判定
        if (this.returnButton.isWithin(x, y)) {
            currentPage = mainPage;
            return;
        }
    }
    /*
     * @override
     */
    onMouseMove(x, y) {
        // 各状態の初期化
        this.itemDetailComponent.starRating.resetTempRating();
        this.slot.prevButton.isActive = false;
        this.slot.nextButton.isActive = false;
        for (let i = 0; i < this.slot.size; i++) {
            let itemComponent = this.slot.itemComponents[i];
            if (itemComponent == null) continue;
            itemComponent.isActive = false;
        }
        this.returnButton.isActive = false;

        // アイテム詳細コンポーネント内の星評価上のマウス移動の判定
        if (this.itemDetailComponent.starRating.isWithin(x, y)) {
            this.canvas.style = 'cursor: pointer';
            let tempRating = this.itemDetailComponent.starRating.getRatingOn(x, y);
            if (tempRating > 0) {
                this.itemDetailComponent.starRating.setTempRating(tempRating);
            }
            return;
        }

        // 推薦スロット内のボタン上のマウス移動の判定
        if (this.slot.prevButton.isWithin(x, y)) {
            this.canvas.style = 'cursor: pointer';
            this.slot.prevButton.isActive = true;
            return;
        } else if (this.slot.nextButton.isWithin(x, y)) {
            this.canvas.style = 'cursor: pointer';
            this.slot.nextButton.isActive = true;
            return;
        }

        // 推薦スロット内のアイテム上のマウス移動の判定
        let itemComponent = this.slot.getItemOn(x, y);
        if (itemComponent != null) {
            this.canvas.style = 'cursor: pointer';
            itemComponent.isActive = true;
            return;
        }

        // 「戻る」ボタン上のマウス移動の判定
        if (this.returnButton.isWithin(x, y)) {
            this.canvas.style = 'cursor: pointer';
            this.returnButton.isActive = true;
            return;
        }
    }
    /*
     * @override
     */
    draw() {
        this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);

        // 対象アイテム
        this.itemDetailComponent.draw(this.context);

        // 推薦スロット
        this.slot.draw(this.context);

        // 「戻る」ボタン
        this.returnButton.draw(this.context);
    }
    /**
     * 対象アイテムをセットする。
     * @param item  対象アイテム
     */
    setItem(item) {
        this.itemDetailComponent = new ItemDetailComponent(DETAIL_ITEM_LEFT, DETAIL_ITEM_TOP, DETAIL_ITEM_WIDTH, DETAIL_ITEM_HEIGHT, item);
    }
    /**
     * 対象アイテムの詳細ページを開く。
     * @param item  対象アイテム
     */
    open(item) {
        this.setItem(item);
        this.getRating();
        this.getSimilarityBasedRecommendations(item.id);
    }
    /**
     * 対象アイテムをベースとした類似度ベース推薦システムによる推薦リストを取得する。
     */
    getSimilarityBasedRecommendations() {
        let thisPage = this;
        $.ajax({
            url: this.itemDetailComponent.item.id.toString() + '/similarity/',
            method: 'GET',
            data: {
            },
            timeout: 10000,
            dataType: "json",
        }).done(function(response) {
            let description = response.description;
            let reclist = response.reclist;
            thisPage.slot.isActive = true;
            thisPage.slot.setRecList(description, reclist);
            thisPage.draw();
        }).fail(function(response) {
            window.alert('DetailPage::getSimilarityBasedRecommendations() : failed');
        });
    }
    /*
     * アクティブユーザの対象アイテムに対する評価値を取得する。
     */
    getRating() {
        let thisPage = this;
        $.ajax({
            url: this.itemDetailComponent.item.id.toString() + '/rating/',
            method: 'GET',
            data: {
            },
            timeout: 10000,
            dataType: "json",
        }).done(function(response) {
            let rating = response.rating;
            thisPage.itemDetailComponent.starRating.setRating(rating);
            thisPage.draw();
        }).fail(function(response) {
            window.alert('DetailPage::getRating() : failed');
        });
    }
    /*
     * アクティブユーザの対象アイテムへの評価値を更新する。
     * @param rating    評価値
     */
    postRating(rating) {
        let thisPage = this;
        $.ajax({
            url: this.itemDetailComponent.item.id.toString() + '/rating/',
            method: 'POST',
            data: {
                'rating': rating,
            },
            timeout: 10000,
            dataType: "json",
        }).done(function(response) {
            thisPage.itemDetailComponent.starRating.setRating(rating);
            thisPage.draw();
        }).fail(function(response) {
            window.alert('DetailPage::postRating() : failed');
        });
    }
}

リスト2: recsys_django/static/js/ItemDetailComponent.js

/**
 * アイテム詳細コンポーネントクラス
 */
class ItemDetailComponent extends Component {
    /**
     * @param item  対象アイテム
     */
    constructor(left, top, width, height, item) {
        super(left, top, width, height);

        this.item = item;       // 対象アイテム

        // 星評価
        this.starRating = new StarRating(this.left + this.width, this.top + 98, DETAIL_STAR_WIDTH * MAX_RATING, DETAIL_STAR_WIDTH, MAX_RATING);
    }
    /**
     * @override
     */
    draw(context) {
        // アイテム枠
        context.save();
        context.fillStyle = '#E0FFEF';
        context.fillRect(this.left, this.top, this.width, this.height);
        context.restore();

        // アイテム画像
        context.save();
        let scale = this.width / this.item.image.width;
        let w = this.width;
        let h = this.item.image.height * scale;
        context.drawImage(this.item.image, this.left, this.top, w, h);
        context.restore();

        // アイテム名
        context.save();
        context.font = '42px メイリオ';
        context.fillStyle = '#434343';
        context.strokeStyle = '#434343';
        context.textBaseline = 'top';
        context.textAlign = 'left';
        context.fillText(this.item.name, this.left + this.width, this.top);
        context.strokeText(this.item.name, this.left + this.width, this.top);
        context.restore();

        // アイテムのカテゴリ
        context.save();
        context.font = '36px メイリオ';
        context.fillStyle = '#838383';
        context.strokeStyle = '#838383';
        context.textBaseline = 'top';
        context.textAlign = 'left';
        let category = null;
        category = this.item.red == 1 ? '赤身' : category;
        category = this.item.white == 1 ? '白身' : category;
        category = this.item.shining == 1 ? '光物' : category;
        context.fillText(category, this.left + this.width, this.top + 52);
        context.strokeText(category, this.left + this.width, this.top + 52);
        context.restore();

        // 星評価
        this.starRating.draw(context);
    }
}

リスト3: recsys_django/static/js/Star.js

/**
 * 星クラス
 */
class Star extends Component {
    /**
     *
     */
    constructor(left, top, width, height) {
        super(left, top, width, height);

        this.isTempActive = false;      // 暫定選択中であるか
    }
    /**
     * @override
     */
    draw(context) {
        context.save();
        let star = this.isTempActive ? 2 : (this.isActive ? 1 : 0);
        context.drawImage(starImages[star], this.left, this.top, this.width, this.height);
        context.restore();
    }
}

リスト4: recsys_django/static/js/StarRating.js

/**
 * 星評価クラス
 */
class StarRating extends Component {
    /**
     * @param maxRating 最大評価値
     */
    constructor(left, top, width, height, maxRating) {
        super(left, top, width, height);

        this.maxRating = maxRating;     // 最大評価値
        this.tempRating = -1;           // 暫定評価値

        // 星配列
        this.stars = new Array(this.maxRating);
        for (let i = 0; i < this.stars.length; i++) {
            this.stars[i] = new Star(this.left + DETAIL_STAR_WIDTH * i, this.top, DETAIL_STAR_WIDTH, DETAIL_STAR_WIDTH);
        }
    }
    /**
     * @override
     */
    draw(context) {
        context.save();
        for (let i = 0; i < this.stars.length; i++) {
            this.stars[i].draw(context);
        }
        context.restore();
    }
    /**
     * 評価値をセットする。
     * @param rating    評価値
     */
    setRating(rating) {
        this.rating = rating;
        for (let i = 0; i < this.stars.length; i++) {
            this.stars[i].isActive = i < this.rating ? true : false;
        }
    }
    /**
     * 暫定評価値をセットする。
     * @param rating    暫定評価値
     */
    setTempRating(tempRating) {
        this.tempRating = tempRating;
        for (let i = 0; i < this.stars.length; i++) {
            this.stars[i].isTempActive = i < this.tempRating ? true : false;
        }
    }
    /**
     * 暫定評価値をリセットする。
     */
    resetTempRating() {
        this.tempRating = -1;
        for (let i = 0; i < this.stars.length; i++) {
            this.stars[i].isTempActive = false;
        }
    }
    /**
     * 点(x, y)上の評価値を取得する。
     * @param x x座標
     * @param y y座標
     * @return  評価値
     */
    getRatingOn(x, y) {
        if (!this.isWithin(x, y)) return -1;
        for (let i = 0; i < this.stars.length; i++) {
            if (!this.stars[i].isWithin(x, y)) continue;
            return i + 1;
        }
    }
}

リスト5: recsys_django/static/js/ReturnButton.js

/**
 * 「戻る」ボタンクラス
 */
class ReturnButton extends Component {
    /**
     *
     */
    constructor(left, top, width, height) {
        super(left, top, width, height);
    }
    /**
     * @override
     */
    draw(context) {
        context.save();
        if (this.isActive) {
            context.drawImage(returnImage, this.left - MARGIN / 2, this.top - MARGIN / 2, this.width + MARGIN, this.height + MARGIN);
        } else {
            context.drawImage(returnImage, this.left, this.top, this.width, this.height);
        }
        context.restore();
    }
}

DetailPage.jsでは詳細ページを管理するDetailPageクラスを定義しています。詳細ページ上には、対象アイテムに関する詳細情報と、アイテム類似度に基づく推薦リスト、メインページへ戻るための「戻る」ボタンを表示しています。対象アイテムの詳細情報はアイテム詳細コンポーネントクラスで管理します。これは、ItemDetailComponent.jsItemDetailComponentクラスとして定義しています。対象アイテムに対して与えられた評価値は星評価システムとして表示します。それを管理するのが、StarRatingクラスとStarクラスです。「戻る」ボタンはReturnButtonクラスで定義しています。

以上のJavaScriptファイルを参照できるように、index.htmlに下記を追加しましょう。

リスト6: recsys_django/online/templates/index.html

    {# --- js --- #}
    <script type="text/javascript" src="{% static 'js/Page.js' %}"></script>
    <script type="text/javascript" src="{% static 'js/MainPage.js' %}"></script>
    <script type="text/javascript" src="{% static 'js/DetailPage.js' %}"></script>          <!-- 追加 -->

    <script type="text/javascript" src="{% static 'js/Item.js' %}"></script>

    <script type="text/javascript" src="{% static 'js/Component.js' %}"></script>
    <script type="text/javascript" src="{% static 'js/ItemComponent.js' %}"></script>
    <script type="text/javascript" src="{% static 'js/NextButton.js' %}"></script>
    <script type="text/javascript" src="{% static 'js/Slot.js' %}"></script>
    <script type="text/javascript" src="{% static 'js/ReturnButton.js' %}"></script>        <!-- 追加 -->
    <script type="text/javascript" src="{% static 'js/Star.js' %}"></script>                <!-- 追加 -->
    <script type="text/javascript" src="{% static 'js/StarRating.js' %}"></script>          <!-- 追加 -->
    <script type="text/javascript" src="{% static 'js/ItemDetailComponent.js' %}"></script> <!-- 追加 -->

    <script type="text/javascript" src="{% static 'js/main.js' %}"></script>

詳細ページのレイアウト調整等には下記の定数を参照しています。main.js定数セクションに下記コードを追加してください。

リスト7: recsys_django/static/js/main.js

/**
 * **** **** **** **** **** **** **** ****
 * 定数
 * **** **** **** **** **** **** **** ****
 */
......
MAX_RATING = 5;

DETAIL_ENLARGE = 1.5;

DETAIL_ITEM_LEFT = 0;
DETAIL_ITEM_TOP = 0;
DETAIL_ITEM_WIDTH = ITEM_WIDTH * DETAIL_ENLARGE;
DETAIL_ITEM_HEIGHT = ITEM_HEIGHT * DETAIL_ENLARGE;

DETAIL_SLOT_LEFT = 0;
DETAIL_SLOT_TOP = DETAIL_ITEM_TOP + DETAIL_ITEM_HEIGHT + MARGIN;

DETAIL_STAR_WIDTH = 32;

DETAIL_RETURN_WIDTH = 64;
DETAIL_RETURN_LEFT = SLOT_WIDTH - DETAIL_RETURN_WIDTH - MARGIN / 2;
DETAIL_RETURN_TOP = DETAIL_SLOT_TOP + SLOT_HEIGHT + MARGIN;

下記のように、main.jsグローバル変数セクションに、詳細ページオブジェクト用のdetailPageとImageオブジェクト用のreturnImagestarImageの宣言を追加してください。

リスト8: recsys_django/static/js/main.js

/**
 * **** **** **** **** **** **** **** ****
 * グローバル変数
 * **** **** **** **** **** **** **** ****
 */
// ページ
let currentPage = null;         // 現在のページ
let mainPage = null;            // メインページ
let detailPage = null;          // 詳細ページ           // 追加

// Imageオブジェクト
let returnImage = null;         // 「戻る」ボタン画像    // 追加
let starImages = null;          // 星画像配列           // 追加

main.jsinit()関数において、各Imageオブジェクトと詳細ページオブジェクトを生成します。

リスト9: recsys_django/static/js/main.js

/**
 * 全体の初期化処理
 */
function init() {
    // キャンバス要素の取得
    let canvas = $('#main_canvas').get(0);
    // 描画コンテキストの取得
    let context = canvas.getContext("2d");

    // イベントリスナの追加
    canvas.addEventListener('click', onCanvasClick, false);
    canvas.addEventListener('mousemove', onCanvasMouseMove, false);
    
    /* 以下を追加 */
    // Imageオブジェクトの生成
    returnImage = new Image();
    returnImage.src = returnSrc;
    returnImage.onload = function() {
        console.log(returnImage.src + " : load completed");
    }
    starImages = new Array();
    for (let i = 0; i < starSrcs.length; i++) {
        starImages[i] = new Image();
        starImages[i].src = starSrcs[i];
        starImages[i].onload = function() {
            console.log(starImages[i].src + " : load completed");
        }
    }

    // ページの生成
    mainPage = new MainPage(canvas, context);
    detailPage = new DetailPage(canvas, context);   // 追加

    // データの初期化
    initData();
}

ここで、メインページへ戻るための「戻る」ボタンの画像ファイルreturn.pngと、星評価の画像ファイルstar0.pngstar1.pngstar2.pngをそれぞれ用意し、recsys_django/static/img/ディレクトリに配置しておきます。recsyslab/recsys-django/contents/recsys_django/static/img/に各画像のサンプルファイルを置いています。これらの画像ファイルを参照するために、下記のようにbase.htmlで画像ファイルへのソースを指定しておきます。

リスト10: recsys_django/online/templates/base.html

        {# --- files --- #}
        <script>
            let returnSrc = "{% static 'img/return.png' %}";
            let starSrcs = new Array(
                "{% static 'img/star0.png' %}",
                "{% static 'img/star1.png' %}",
                "{% static 'img/star2.png' %}",
            );
        </script>

ブラウザで下記のURLにアクセスしてみましょう。

http://localhost:8000/

インタフェース

推薦スロットから、例えば「カツオ」をクリックしてみましょう。エラーメッセージが表示されますが、現時点では無視して「OK」ボタンをクリックしてください。すると、上図のように、対象アイテムである「カツオ」に関する詳細情報と共に、「カツオが好きな人はこんな寿司も好きです。」と表示されました。これはアイテム類似度に基づく推薦リストを表します。

対象アイテムの詳細情報の部分には、アイテム名とカテゴリ、評価値を表す星評価が表示されています。星評価にマウスカーソルを重ねると、星がハイライト表示されます。本来であれば、星をクリックすることで対象アイテムに対する評価値を登録できるのですが、現時点では、エラーメッセージが表示されます。対象アイテムの評価値の取得と登録については、ユーザログイン機能を実装した後で実装しましょう。