<commit_before>/**
 * mSupply Mobile
 * Sustainable Solutions (NZ) Ltd. 2019
 */

import React from 'react';
import PropTypes from 'prop-types';

import { View } from 'react-native';

import { GenericPage } from './GenericPage';

import { createRecord } from '../database';
import { buttonStrings, modalStrings, pageInfoStrings, tableStrings } from '../localization';
import { SETTINGS_KEYS } from '../settings';
import { formatDate, parsePositiveInteger, sortDataBy } from '../utilities';
import {
  AutocompleteSelector,
  BottomConfirmModal,
  PageButton,
  PageInfo,
  PageContentModal,
  TextEditor,
  ToggleSelector,
} from '../widgets';

import globalStyles from '../globalStyles';

const DATA_TYPES_SYNCHRONISED = ['RequisitionItem', 'Item', 'ItemBatch'];

const MODAL_KEYS = {
  COMMENT_EDIT: 'commentEdit',
  ITEM_SELECT: 'itemSelect',
  MONTHS_SELECT: 'monthsSelect',
};

export class SupplierRequisitionPage extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      modalKey: null,
      modalIsOpen: false,
      selection: [],
      isProgramOrder: false,
    };
    this.dataFilters = {
      searchTerm: '',
      sortBy: 'itemName',
      isAscending: true,
    };
  }

  componentDidMount() {
    const { requisition, settings } = this.props;
    if (requisition.program) {
      const isProgramOrder = requisition.program.canUseProgram(
        settings.get(SETTINGS_KEYS.THIS_STORE_TAGS)
      );
      this.setState({ isProgramOrder });
      this.onAddMasterItems();
    }
  }

  onAddMasterItems = () => {
    const { database, requisition, runWithLoadingIndicator } = this.props;
    const { isProgramOrder } = this.state;
    runWithLoadingIndicator(() => {
      database.write(() => {
        if (isProgramOrder) requisition.addItemsFromProgram(database);
        else requisition.addItemsFromMasterList(database, this.getThisStore());
        database.save('Requisition', requisition);
      });
      this.refreshData();
    });
  };

  onCreateAutomaticOrder = () => {
    const { database, requisition, runWithLoadingIndicator } = this.props;

    runWithLoadingIndicator(() => {
      database.write(() => {
        requisition.createAutomaticOrder(database, this.getThisStore());
        database.save('Requisition', requisition);
      });
      this.refreshData();
    });
  };

  /**
   * Respond to the user editing the number in the required quantity column.
   *
   * @param   {string}  key              Should always be |requiredQuantity|.
   * @param   {object}  requisitionItem  The requisition item from the row being edited.
   * @param   {string}  newValue         The value the user entered in the cell.
   * @return  {none}
   */
  onEndEditing = (key, requisitionItem, newValue) => {
    const { database } = this.props;

    if (key !== 'requiredQuantity') return;

    database.write(() => {
      requisitionItem.requiredQuantity = parsePositiveInteger(newValue);
      database.save('RequisitionItem', requisitionItem);
    });
  };

  onDeleteConfirm = () => {
    const { requisition, database } = this.props;
    const { selection } = this.state;

    database.write(() => {
      requisition.removeItemsById(database, selection);
      database.save('Requisition', requisition);
    });
    this.setState({ selection: [] });
    this.refreshData();
  };

  onDeleteCancel = () => {
    this.setState({ selection: [] });
    this.refreshData();
  };

  onUseSuggestedQuantities = () => {
    const { runWithLoadingIndicator } = this.props;

    runWithLoadingIndicator(() => {
      const { database, requisition } = this.props;
      database.write(() => {
        requisition.setRequestedToSuggested(database);
        database.save('Requisition', requisition);
      });
      this.refreshData();
    });
  };

  onSelectionChange = newSelection => this.setState({ selection: newSelection });

  getThisStore = () => {
    const { database, settings } = this.props;

    const thisStoreNameId = settings.get(SETTINGS_KEYS.THIS_STORE_NAME_ID);
    const nameResults = database.objects('Name').filtered('id == $0', thisStoreNameId);

    if (!nameResults || nameResults.length <= 0) return null;

    return nameResults[0];
  };

  getModalTitle = () => {
    const { modalKey } = this.state;

    const { ITEM_SELECT, COMMENT_EDIT, MONTHS_SELECT } = MODAL_KEYS;

    switch (modalKey) {
      default:
      case ITEM_SELECT:
        return modalStrings.search_for_an_item_to_add;
      case COMMENT_EDIT:
        return modalStrings.edit_the_requisition_comment;
      case MONTHS_SELECT:
        return modalStrings.select_the_number_of_months_stock_required;
    }
  };

  updateDataFilters = (newSearchTerm, newSortBy, newIsAscending) => {
    // (... != null) checks for null or undefined (implicitly type coerced to null).
    if (newSearchTerm != null) this.dataFilters.searchTerm = newSearchTerm;
    if (newSortBy != null) this.dataFilters.sortBy = newSortBy;
    if (newIsAscending != null) this.dataFilters.isAscending = newIsAscending;
  };

  /**
   * Returns updated data filtered by |searchTerm| and ordered by |sortBy| and |isAscending|.
   */
  refreshData = (newSearchTerm, newSortBy, newIsAscending) => {
    const { requisition } = this.props;

    this.updateDataFilters(newSearchTerm, newSortBy, newIsAscending);
    const { searchTerm, sortBy, isAscending } = this.dataFilters;

    const data = requisition.items.filtered(
      'item.name BEGINSWITH[c] $0 OR item.code BEGINSWITH[c] $0',
      searchTerm
    );

    let sortDataType;
    switch (sortBy) {
      case 'itemCode':
      case 'itemName':
        sortDataType = 'string';
        break;
      case 'monthlyUsage':
      case 'suggestedQuantity':
      case 'requiredQuantity':
        sortDataType = 'number';
        break;
      default:
        sortDataType = 'realm';
    }
    this.setState({
      data: sortDataBy(data, sortBy, sortDataType, isAscending),
    });
  };

  openModal = key => this.setState({ modalKey: key, modalIsOpen: true });

  closeModal = () => this.setState({ modalIsOpen: false });

  openItemSelector = () => this.openModal(MODAL_KEYS.ITEM_SELECT);

  openMonthsSelector = () => this.openModal(MODAL_KEYS.MONTHS_SELECT);

  openCommentEditor = () => this.openModal(MODAL_KEYS.COMMENT_EDIT);

  renderPageInfo = () => {
    const { requisition } = this.props;
    const infoColumns = [
      [
        {
          title: `${pageInfoStrings.entry_date}:`,
          info: formatDate(requisition.entryDate),
        },
        {
          title: `${pageInfoStrings.entered_by}:`,
          info: requisition.enteredByName,
        },
      ],
      [
        {
          title: `${pageInfoStrings.supplier}:`,
          info: requisition.otherStoreName ? requisition.otherStoreName.name : '',
        },
        {
          title: `${pageInfoStrings.months_stock_required}:`,
          info: requisition.monthsToSupply,
          onPress: this.openMonthsSelector,
          editableType: 'selectable',
        },
        {
          title: `${pageInfoStrings.comment}:`,
          info: requisition.comment,
          onPress: this.openCommentEditor,
          editableType: 'text',
        },
      ],
    ];
    return <PageInfo columns={infoColumns} isEditingDisabled={requisition.isFinalised} />;
  };

  renderCell = (key, requisitionItem) => {
    const { requisition } = this.props;

    switch (key) {
      default:
        return requisitionItem[key];
      case 'monthlyUsage':
      case 'suggestedQuantity':
        return Math.round(requisitionItem[key]);
      case 'requiredQuantity':
        return {
          type: requisition.isFinalised ? 'text' : 'editable',
          cellContents: Math.round(requisitionItem.requiredQuantity),
        };
      case 'remove':
        return {
          type: 'checkable',
          icon: 'md-remove-circle',
          isDisabled: requisition.isFinalised,
        };
    }
  };

  renderModalContent = () => {
    const { modalKey } = this.state;
    const { database, requisition } = this.props;

    const { COMMENT_EDIT, ITEM_SELECT, MONTHS_SELECT } = MODAL_KEYS;

    switch (modalKey) {
      default:
      case ITEM_SELECT:
        return (
          <AutocompleteSelector
            options={database.objects('Item')}
            queryString="name BEGINSWITH[c] $0 OR code BEGINSWITH[c] $0"
            queryStringSecondary="name CONTAINS[c] $0"
            sortByString="name"
            onSelect={item => {
              database.write(() => {
                if (!requisition.hasItem(item)) {
                  createRecord(database, 'RequisitionItem', requisition, item);
                }
              });
              this.refreshData();
              this.closeModal();
            }}
            renderLeftText={item => `${item.name}`}
            renderRightText={item => `${item.totalQuantity}`}
          />
        );
      case MONTHS_SELECT:
        return (
          <ToggleSelector
            options={[1, 2, 3, 4, 5, 6]}
            onSelect={number => {
              database.write(() => {
                requisition.monthsToSupply = number;
                database.save('Requisition', requisition);
              });
              this.refreshData();
              this.closeModal();
            }}
            selected={requisition.monthsToSupply}
          />
        );
      case COMMENT_EDIT:
        return (
          <TextEditor
            text={requisition.comment}
            onEndEditing={newComment => {
              if (newComment !== requisition.comment) {
                database.write(() => {
                  requisition.comment = newComment;
                  database.save('Requisition', requisition);
                });
              }
              this.closeModal();
            }}
          />
        );
    }
  };

  renderButtons = () => {
    const { requisition } = this.props;

    return (
      <View style={globalStyles.pageTopRightSectionContainer}>
        <View style={globalStyles.verticalContainer}>
          <PageButton
            style={globalStyles.topButton}
            text={buttonStrings.create_automatic_order}
            onPress={this.onCreateAutomaticOrder}
            isDisabled={requisition.isFinalised}
          />
          <PageButton
            style={globalStyles.leftButton}
            text={buttonStrings.use_suggested_quantities}
            onPress={this.onUseSuggestedQuantities}
            isDisabled={requisition.isFinalised}
          />
        </View>
        <View style={globalStyles.verticalContainer}>
          <PageButton
            style={globalStyles.topButton}
            text={buttonStrings.new_item}
            onPress={() => this.openModal(MODAL_KEYS.ITEM_SELECT)}
            isDisabled={requisition.isFinalised}
          />
          <PageButton
            text={buttonStrings.add_master_list_items}
            onPress={this.onAddMasterItems}
            isDisabled={requisition.isFinalised}
          />
        </View>
      </View>
    );
  };

  render() {
    const { database, genericTablePageStyles, requisition, topRoute } = this.props;
    const { data, modalIsOpen, selection } = this.state;

    return (
      <GenericPage
        data={data}
        refreshData={this.refreshData}
        renderCell={this.renderCell}
        renderTopLeftComponent={this.renderPageInfo}
        renderTopRightComponent={this.renderButtons}
        onEndEditing={this.onEndEditing}
        onSelectionChange={this.onSelectionChange}
        defaultSortKey={this.dataFilters.sortBy}
        defaultSortDirection={this.dataFilters.isAscending ? 'ascending' : 'descending'}
        columns={[
          {
            key: 'itemCode',
            width: 1.5,
            title: tableStrings.code,
            sortable: true,
          },
          {
            key: 'itemName',
            width: 4,
            title: tableStrings.item_name,
            sortable: true,
          },
          {
            key: 'stockOnHand',
            width: 2,
            title: tableStrings.current_stock,
            sortable: true,
            alignText: 'right',
          },
          {
            key: 'monthlyUsage',
            width: 2,
            title: tableStrings.monthly_usage,
            sortable: true,
            alignText: 'right',
          },
          {
            key: 'suggestedQuantity',
            width: 2,
            title: tableStrings.suggested_quantity,
            sortable: true,
            alignText: 'right',
          },
          {
            key: 'requiredQuantity',
            width: 2,
            title: tableStrings.required_quantity,
            sortable: true,
            alignText: 'right',
          },
          {
            key: 'remove',
            width: 1,
            title: tableStrings.remove,
            alignText: 'center',
          },
        ]}
        dataTypesSynchronised={DATA_TYPES_SYNCHRONISED}
        finalisableDataType="Requisition"
        database={database}
        selection={selection}
        {...genericTablePageStyles}
        topRoute={topRoute}
      >
        <BottomConfirmModal
          isOpen={selection.length > 0 && !requisition.isFinalised}
          questionText={modalStrings.remove_these_items}
          onCancel={this.onDeleteCancel}
          onConfirm={this.onDeleteConfirm}
          confirmText={modalStrings.remove}
        />
        <PageContentModal
          isOpen={modalIsOpen && !requisition.isFinalised}
          onClose={this.closeModal}
          title={this.getModalTitle()}
        >
          {this.renderModalContent()}
        </PageContentModal>
      </GenericPage>
    );
  }
}

/**
 * Check whether a given requisition is safe to be finalised. Return null if safe,
 * else return an appropriate error message.
 *
 * @param   {object}  requisition  The requisition to check.
 * @return  {string}               Null if safe to finalise, else an error message.
 */
export function checkForFinaliseError(requisition) {
  if (requisition.items.length === 0) {
    return modalStrings.add_at_least_one_item_before_finalising;
  }

  if (requisition.totalRequiredQuantity === 0) {
    return modalStrings.record_stock_required_before_finalising;
  }

  return null;
}

/* eslint-disable react/forbid-prop-types, react/require-default-props */
SupplierRequisitionPage.propTypes = {
  database: PropTypes.object.isRequired,
  genericTablePageStyles: PropTypes.object,
  topRoute: PropTypes.bool,
  runWithLoadingIndicator: PropTypes.func.isRequired,
  requisition: PropTypes.object.isRequired,
  settings: PropTypes.object.isRequired,
};
<commit_msg>Add update to SupplierRequisitionPage to handle program requisitions<commit_after>/**
 * mSupply Mobile
 * Sustainable Solutions (NZ) Ltd. 2019
 */

import React from 'react';
import PropTypes from 'prop-types';

import { View } from 'react-native';

import { GenericPage } from './GenericPage';

import { createRecord } from '../database';
import { buttonStrings, modalStrings, pageInfoStrings, tableStrings } from '../localization';
import { SETTINGS_KEYS } from '../settings';
import { formatDate, parsePositiveInteger, sortDataBy } from '../utilities';
import {
  AutocompleteSelector,
  BottomConfirmModal,
  PageButton,
  PageInfo,
  PageContentModal,
  TextEditor,
  ToggleSelector,
} from '../widgets';

import globalStyles from '../globalStyles';

const DATA_TYPES_SYNCHRONISED = ['RequisitionItem', 'Item', 'ItemBatch'];

const MODAL_KEYS = {
  COMMENT_EDIT: 'commentEdit',
  ITEM_SELECT: 'itemSelect',
  MONTHS_SELECT: 'monthsSelect',
};

export class SupplierRequisitionPage extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      modalKey: null,
      modalIsOpen: false,
      selection: [],
      isProgramOrder: false,
      orderType: null,
    };
    this.dataFilters = {
      searchTerm: '',
      sortBy: 'itemName',
      isAscending: true,
    };
  }

  componentDidMount = () => {
    const { requisition, settings } = this.props;
    if (requisition.program) {
      const tags = settings.get(SETTINGS_KEYS.THIS_STORE_TAGS);
      const orderType = requisition.program.getOrderType(tags, requisition.orderType);
      this.setState({ isProgramOrder: !!requisition.program, orderType });
    }
  };

  onAddMasterItems = () => {
    const { database, requisition, runWithLoadingIndicator } = this.props;
    runWithLoadingIndicator(() => {
      database.write(() => {
        requisition.addItemsFromMasterList(database, this.getThisStore());
        database.save('Requisition', requisition);
      });
      this.refreshData();
    });
  };

  onCreateAutomaticOrder = () => {
    const { database, requisition, runWithLoadingIndicator } = this.props;

    runWithLoadingIndicator(() => {
      database.write(() => {
        requisition.createAutomaticOrder(database, this.getThisStore());
        database.save('Requisition', requisition);
      });
      this.refreshData();
    });
  };

  /**
   * Respond to the user editing the number in the required quantity column.
   *
   * @param   {string}  key              Should always be |requiredQuantity|.
   * @param   {object}  requisitionItem  The requisition item from the row being edited.
   * @param   {string}  newValue         The value the user entered in the cell.
   * @return  {none}
   */
  onEndEditing = (key, requisitionItem, newValue) => {
    const { database } = this.props;

    if (key !== 'requiredQuantity') return;

    database.write(() => {
      requisitionItem.requiredQuantity = parsePositiveInteger(newValue);
      database.save('RequisitionItem', requisitionItem);
    });
  };

  onDeleteConfirm = () => {
    const { requisition, database } = this.props;
    const { selection } = this.state;

    database.write(() => {
      requisition.removeItemsById(database, selection);
      database.save('Requisition', requisition);
    });
    this.setState({ selection: [] });
    this.refreshData();
  };

  onDeleteCancel = () => {
    this.setState({ selection: [] });
    this.refreshData();
  };

  onUseSuggestedQuantities = () => {
    const { runWithLoadingIndicator } = this.props;

    runWithLoadingIndicator(() => {
      const { database, requisition } = this.props;
      database.write(() => {
        requisition.setRequestedToSuggested(database);
        database.save('Requisition', requisition);
      });
      this.refreshData();
    });
  };

  onSelectionChange = newSelection => this.setState({ selection: newSelection });

  getThisStore = () => {
    const { database, settings } = this.props;

    const thisStoreNameId = settings.get(SETTINGS_KEYS.THIS_STORE_NAME_ID);
    const nameResults = database.objects('Name').filtered('id == $0', thisStoreNameId);

    if (!nameResults || nameResults.length <= 0) return null;

    return nameResults[0];
  };

  getModalTitle = () => {
    const { modalKey } = this.state;

    const { ITEM_SELECT, COMMENT_EDIT, MONTHS_SELECT } = MODAL_KEYS;

    switch (modalKey) {
      default:
      case ITEM_SELECT:
        return modalStrings.search_for_an_item_to_add;
      case COMMENT_EDIT:
        return modalStrings.edit_the_requisition_comment;
      case MONTHS_SELECT:
        return modalStrings.select_the_number_of_months_stock_required;
    }
  };

  updateDataFilters = (newSearchTerm, newSortBy, newIsAscending) => {
    // (... != null) checks for null or undefined (implicitly type coerced to null).
    if (newSearchTerm != null) this.dataFilters.searchTerm = newSearchTerm;
    if (newSortBy != null) this.dataFilters.sortBy = newSortBy;
    if (newIsAscending != null) this.dataFilters.isAscending = newIsAscending;
  };

  /**
   * Returns updated data filtered by |searchTerm| and ordered by |sortBy| and |isAscending|.
   */
  refreshData = (newSearchTerm, newSortBy, newIsAscending) => {
    const { requisition } = this.props;

    this.updateDataFilters(newSearchTerm, newSortBy, newIsAscending);
    const { searchTerm, sortBy, isAscending } = this.dataFilters;

    const data = requisition.items.filtered(
      'item.name BEGINSWITH[c] $0 OR item.code BEGINSWITH[c] $0',
      searchTerm
    );

    let sortDataType;
    switch (sortBy) {
      case 'itemCode':
      case 'itemName':
        sortDataType = 'string';
        break;
      case 'monthlyUsage':
      case 'suggestedQuantity':
      case 'requiredQuantity':
        sortDataType = 'number';
        break;
      default:
        sortDataType = 'realm';
    }
    this.setState({
      data: sortDataBy(data, sortBy, sortDataType, isAscending),
    });
  };

  openModal = key => this.setState({ modalKey: key, modalIsOpen: true });

  closeModal = () => this.setState({ modalIsOpen: false });

  openItemSelector = () => this.openModal(MODAL_KEYS.ITEM_SELECT);

  openMonthsSelector = () => this.openModal(MODAL_KEYS.MONTHS_SELECT);

  openCommentEditor = () => this.openModal(MODAL_KEYS.COMMENT_EDIT);

  renderPageInfo = () => {
    const { requisition } = this.props;
    const { isProgramOrder } = this.state;
    const infoColumns = [
      [
        {
          title: `${pageInfoStrings.entry_date}:`,
          info: formatDate(requisition.entryDate),
        },
        {
          title: `${pageInfoStrings.entered_by}:`,
          info: requisition.enteredByName,
        },
      ],
      [
        {
          title: `${pageInfoStrings.supplier}:`,
          info: requisition.otherStoreName ? requisition.otherStoreName.name : '',
        },
        {
          title: `${pageInfoStrings.months_stock_required}:`,
          info: requisition.monthsToSupply,
          onPress: this.openMonthsSelector,
          editableType: 'selectable',
        },
        {
          title: `${pageInfoStrings.comment}:`,
          info: requisition.comment,
          onPress: this.openCommentEditor,
          editableType: 'text',
        },
      ],
    ];
    if (isProgramOrder) {
      const { period, program } = requisition;
      const { orderType } = this.state;
      const programInfo = { title: 'Program:', info: program.name };
      const orderTypeInfo = { title: 'Order Type:', info: orderType.name };
      const periodInfo = {
        title: 'Period:',
        info: `${period.name} -- ${period.startDate.toLocaleDateString(
          'en-US'
        )} - ${period.endDate.toLocaleDateString('en-US')}`,
      };

      infoColumns[0].unshift(orderTypeInfo);
      infoColumns[0].unshift(programInfo);
      infoColumns[1].unshift(periodInfo);
    }
    return <PageInfo columns={infoColumns} isEditingDisabled={requisition.isFinalised} />;
  };

  renderCell = (key, requisitionItem) => {
    const { requisition } = this.props;

    switch (key) {
      default:
        return requisitionItem[key];
      case 'monthlyUsage':
      case 'suggestedQuantity':
        return Math.round(requisitionItem[key]);
      case 'requiredQuantity':
        return {
          type: requisition.isFinalised ? 'text' : 'editable',
          cellContents: Math.round(requisitionItem.requiredQuantity),
        };
      case 'remove':
        return {
          type: 'checkable',
          icon: 'md-remove-circle',
          isDisabled: requisition.isFinalised,
        };
    }
  };

  renderModalContent = () => {
    const { modalKey } = this.state;
    const { database, requisition } = this.props;

    const { COMMENT_EDIT, ITEM_SELECT, MONTHS_SELECT } = MODAL_KEYS;

    switch (modalKey) {
      default:
      case ITEM_SELECT:
        return (
          <AutocompleteSelector
            options={database.objects('Item')}
            queryString="name BEGINSWITH[c] $0 OR code BEGINSWITH[c] $0"
            queryStringSecondary="name CONTAINS[c] $0"
            sortByString="name"
            onSelect={item => {
              database.write(() => {
                if (!requisition.hasItem(item)) {
                  createRecord(database, 'RequisitionItem', requisition, item);
                }
              });
              this.refreshData();
              this.closeModal();
            }}
            renderLeftText={item => `${item.name}`}
            renderRightText={item => `${item.totalQuantity}`}
          />
        );
      case MONTHS_SELECT:
        return (
          <ToggleSelector
            options={[1, 2, 3, 4, 5, 6]}
            onSelect={number => {
              database.write(() => {
                requisition.monthsToSupply = number;
                database.save('Requisition', requisition);
              });
              this.refreshData();
              this.closeModal();
            }}
            selected={requisition.monthsToSupply}
          />
        );
      case COMMENT_EDIT:
        return (
          <TextEditor
            text={requisition.comment}
            onEndEditing={newComment => {
              if (newComment !== requisition.comment) {
                database.write(() => {
                  requisition.comment = newComment;
                  database.save('Requisition', requisition);
                });
              }
              this.closeModal();
            }}
          />
        );
    }
  };

  renderButtons = () => {
    const { requisition } = this.props;
    const { isProgramOrder } = this.state;
    return (
      <View style={globalStyles.pageTopRightSectionContainer}>
        <View style={globalStyles.verticalContainer}>
          <PageButton
            style={globalStyles.topButton}
            text={buttonStrings.create_automatic_order}
            onPress={this.onCreateAutomaticOrder}
            isDisabled={requisition.isFinalised}
          />
          <PageButton
            style={globalStyles.leftButton}
            text={buttonStrings.use_suggested_quantities}
            onPress={this.onUseSuggestedQuantities}
            isDisabled={requisition.isFinalised}
          />
        </View>

        {!isProgramOrder && (
          <View style={globalStyles.verticalContainer}>
            <PageButton
              style={globalStyles.topButton}
              text={buttonStrings.new_item}
              onPress={() => this.openModal(MODAL_KEYS.ITEM_SELECT)}
              isDisabled={requisition.isFinalised}
            />
            <PageButton
              text={buttonStrings.add_master_list_items}
              onPress={this.onAddMasterItems}
              isDisabled={requisition.isFinalised}
            />
          </View>
        )}
      </View>
    );
  };

  render() {
    const { database, genericTablePageStyles, requisition, topRoute } = this.props;
    const { data, modalIsOpen, selection } = this.state;

    return (
      <GenericPage
        data={data}
        refreshData={this.refreshData}
        renderCell={this.renderCell}
        renderTopLeftComponent={this.renderPageInfo}
        renderTopRightComponent={this.renderButtons}
        onEndEditing={this.onEndEditing}
        onSelectionChange={this.onSelectionChange}
        defaultSortKey={this.dataFilters.sortBy}
        defaultSortDirection={this.dataFilters.isAscending ? 'ascending' : 'descending'}
        columns={[
          {
            key: 'itemCode',
            width: 1.5,
            title: tableStrings.code,
            sortable: true,
          },
          {
            key: 'itemName',
            width: 4,
            title: tableStrings.item_name,
            sortable: true,
          },
          {
            key: 'stockOnHand',
            width: 2,
            title: tableStrings.current_stock,
            sortable: true,
            alignText: 'right',
          },
          {
            key: 'monthlyUsage',
            width: 2,
            title: tableStrings.monthly_usage,
            sortable: true,
            alignText: 'right',
          },
          {
            key: 'suggestedQuantity',
            width: 2,
            title: tableStrings.suggested_quantity,
            sortable: true,
            alignText: 'right',
          },
          {
            key: 'requiredQuantity',
            width: 2,
            title: tableStrings.required_quantity,
            sortable: true,
            alignText: 'right',
          },
          {
            key: 'remove',
            width: 1,
            title: tableStrings.remove,
            alignText: 'center',
          },
        ]}
        dataTypesSynchronised={DATA_TYPES_SYNCHRONISED}
        finalisableDataType="Requisition"
        database={database}
        selection={selection}
        {...genericTablePageStyles}
        topRoute={topRoute}
      >
        <BottomConfirmModal
          isOpen={selection.length > 0 && !requisition.isFinalised}
          questionText={modalStrings.remove_these_items}
          onCancel={this.onDeleteCancel}
          onConfirm={this.onDeleteConfirm}
          confirmText={modalStrings.remove}
        />
        <PageContentModal
          isOpen={modalIsOpen && !requisition.isFinalised}
          onClose={this.closeModal}
          title={this.getModalTitle()}
        >
          {this.renderModalContent()}
        </PageContentModal>
      </GenericPage>
    );
  }
}

/**
 * Check whether a given requisition is safe to be finalised. Return null if safe,
 * else return an appropriate error message.
 *
 * @param   {object}  requisition  The requisition to check.
 * @return  {string}               Null if safe to finalise, else an error message.
 */
export function checkForFinaliseError(requisition) {
  if (requisition.items.length === 0) {
    return modalStrings.add_at_least_one_item_before_finalising;
  }

  if (requisition.totalRequiredQuantity === 0) {
    return modalStrings.record_stock_required_before_finalising;
  }

  return null;
}

/* eslint-disable react/forbid-prop-types, react/require-default-props */
SupplierRequisitionPage.propTypes = {
  database: PropTypes.object.isRequired,
  genericTablePageStyles: PropTypes.object,
  topRoute: PropTypes.bool,
  runWithLoadingIndicator: PropTypes.func.isRequired,
  requisition: PropTypes.object.isRequired,
  settings: PropTypes.object.isRequired,
};
<|endoftext|>


<commit_before>include('Javascript/core/game/gridModel.js');
include('Javascript/core/game/theme-generator.js');

var GameView = new MAF.Class({
	ClassName: 'GameView',

	Extends: MAF.system.FullscreenView,

	createView: function () {
		new MAF.element.Text({
			text: 'tv48',
			styles:{
				width: this.width,
				fontSize: 60,
				anchorStyle: 'center'
			}
		}).appendTo(this);
	},

	updateView: function () {
		var view = this;

		view.model = new Grid(view.persist.rows, view.persist.cols);
		view.onCellAdd_bound = view.onCellAdd.subscribeTo(view.model, 'addCell', view);

		ThemeGenerator.generateCellsStyles(view.model.cellWidth);

		view.renderGrid(view.model, view);

		view.model.generateCells(2, 2);

		view.onNavigate_bound = view.navigate.subscribeTo(view, 'onNavigate', view);

		view.isNavigating = false;
	},

	hideView: function () {
		var view = this;

		view.onCellAdd_bound.unsubscribeFrom(view.model, 'addCell');
		view.onNavigate_bound.unsubscribeFrom(view, 'onNavigate');

		view.model.destroy();
		this.elements.gridBg.suicide();
	},

	renderGrid: function (grid, container) {
		var view = this,
			gridBg = new MAF.element.Container({
			styles: {
				vOffset: 100,
				hOffset: (container.width - grid.gridWidth) / 2,
				width: grid.gridWidth,
				height: grid.gridHeight,
				backgroundColor: Theme.getStyles('GridBackground', 'backgroundColor')
			}
		}).appendTo(container);

		this.elements.gridBg = gridBg;

		for (var i = 0; i < grid.rows; i++) {
			for (var j = 0; j < grid.cols; j++) {
				this.renderCellPlaceholder(i, j).appendTo(gridBg);
			}
		}
	},

	renderCellPlaceholder: function (row, col) {
		var grid = this.model;

		return new MAF.element.Container({
			styles: {
				vOffset: grid.cellGap + (row * (grid.cellHeight + grid.cellGap)),
				hOffset: grid.cellGap + (col * (grid.cellWidth + grid.cellGap)),
				width: grid.cellWidth,
				height: grid.cellHeight,
				backgroundColor: Theme.getStyles('CellPlaceholderBackground', 'backgroundColor')
			}
		});
	},

	onCellAdd: function (e) {
		var cell = e.payload;

		this.renderCell(cell.row, cell.col, cell.value)
			.appendTo(this.elements.gridBg);
	},

	renderCell: function (row, col, value) {
		var grid = this.model,
			element;

		element = new MAF.element.Container({
			styles: {
				vOffset: grid.cellGap + (row * (grid.cellHeight + grid.cellGap)),
				hOffset: grid.cellGap + (col * (grid.cellWidth + grid.cellGap)),
				width: grid.cellWidth,
				height: grid.cellHeight
			},
			content: [
				new MAF.element.Text({
					label: value,
					styles: {
						anchorStyle: 'center',
						width: grid.cellWidth,
						height: grid.cellHeight
					}
				})
			]
		});

		grid.setCellElement(row, col, element);

		return element;
	},

	navigate: function (event) {
		var view = this,
			direction = this.getDirection(event);

		if (!direction || view.isNavigating) {
			return;
		}

		view.isNavigating = true;
		view.model.navigate(direction, function __onNavigationEnd__() {
			view.model.generateCells(1, 2);
			view.isNavigating = false;
		});
	},

	getDirection: function (event) {
		var key;

		if (event && event.payload) {
			key = event.payload.direction;
		} else if (event && event.Event && event.Event.detail) {
			key = event.Event.detail.direction;
		}

		return key;
	}
});<commit_msg>Add animation on cell creation;<commit_after>include('Javascript/core/game/gridModel.js');
include('Javascript/core/game/theme-generator.js');

var GameView = new MAF.Class({
	ClassName: 'GameView',

	Extends: MAF.system.FullscreenView,

	createView: function () {
		new MAF.element.Text({
			text: 'tv48',
			styles:{
				width: this.width,
				fontSize: 60,
				anchorStyle: 'center'
			}
		}).appendTo(this);
	},

	updateView: function () {
		var view = this;

		view.model = new Grid(view.persist.rows, view.persist.cols);
		view.onCellAdd_bound = view.onCellAdd.subscribeTo(view.model, 'addCell', view);

		ThemeGenerator.generateCellsStyles(view.model.cellWidth);

		view.renderGrid(view.model, view);

		view.model.generateCells(2, 2);

		view.onNavigate_bound = view.navigate.subscribeTo(view, 'onNavigate', view);

		view.isNavigating = false;
	},

	hideView: function () {
		var view = this;

		view.onCellAdd_bound.unsubscribeFrom(view.model, 'addCell');
		view.onNavigate_bound.unsubscribeFrom(view, 'onNavigate');

		view.model.destroy();
		this.elements.gridBg.suicide();
	},

	renderGrid: function (grid, container) {
		var view = this,
			gridBg = new MAF.element.Container({
			styles: {
				vOffset: 100,
				hOffset: (container.width - grid.gridWidth) / 2,
				width: grid.gridWidth,
				height: grid.gridHeight,
				backgroundColor: Theme.getStyles('GridBackground', 'backgroundColor')
			}
		}).appendTo(container);

		this.elements.gridBg = gridBg;

		for (var i = 0; i < grid.rows; i++) {
			for (var j = 0; j < grid.cols; j++) {
				this.renderCellPlaceholder(i, j).appendTo(gridBg);
			}
		}
	},

	renderCellPlaceholder: function (row, col) {
		var grid = this.model;

		return new MAF.element.Container({
			styles: {
				vOffset: grid.cellGap + (row * (grid.cellHeight + grid.cellGap)),
				hOffset: grid.cellGap + (col * (grid.cellWidth + grid.cellGap)),
				width: grid.cellWidth,
				height: grid.cellHeight,
				backgroundColor: Theme.getStyles('CellPlaceholderBackground', 'backgroundColor')
			}
		});
	},

	onCellAdd: function (e) {
		var cell = e.payload;

		this.renderCell(cell.row, cell.col, cell.value)
			.appendTo(this.elements.gridBg)
			.animate({
				duration: 0.15,
				scale: 1.1,
				callback: function () {
					this.element.animate({
						duration: 0.15,
						scale: 1
					})
				}
			});
	},

	renderCell: function (row, col, value) {
		var grid = this.model,
			element;

		element = new MAF.element.Container({
			styles: {
				vOffset: grid.cellGap + (row * (grid.cellHeight + grid.cellGap)),
				hOffset: grid.cellGap + (col * (grid.cellWidth + grid.cellGap)),
				width: grid.cellWidth,
				height: grid.cellHeight
			},
			content: [
				new MAF.element.Text({
					label: value,
					styles: {
						anchorStyle: 'center',
						width: grid.cellWidth,
						height: grid.cellHeight
					}
				})
			]
		});

		grid.setCellElement(row, col, element);

		return element;
	},

	navigate: function (event) {
		var view = this,
			direction = this.getDirection(event);

		if (!direction || view.isNavigating) {
			return;
		}

		view.isNavigating = true;
		view.model.navigate(direction, function __onNavigationEnd__() {
			view.model.generateCells(1, 2);
			view.isNavigating = false;
		});
	},

	getDirection: function (event) {
		var key;

		if (event && event.payload) {
			key = event.payload.direction;
		} else if (event && event.Event && event.Event.detail) {
			key = event.Event.detail.direction;
		}

		return key;
	}
});<|endoftext|>


<commit_before>import React from 'react';

import { Zerotorescue } from 'CONTRIBUTORS';
import SPELLS from 'common/SPELLS';
import SpellLink from 'common/SpellLink';

export default [
  {
    date: new Date('2019-02-24'),
    changes: <><SpellLink id={SPELLS.GLIMMER_OF_LIGHT.id} /> has been marked as scaling with Mastery and Haste and should now affect the stat values.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2019-01-30'),
    changes: <><SpellLink id={SPELLS.GLIMMER_OF_LIGHT.id} /> has been added to the list of spells that beacon transfer (50% efficiency).</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2019-01-05'),
    changes: <><SpellLink id={SPELLS.LIGHT_OF_THE_MARTYR.id} /> that did negative healing are now highlighted in the timeline.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2019-01-05'),
    changes: <><SpellLink id={SPELLS.LIGHT_OF_THE_MARTYR.id} /> cast while <SpellLink id={SPELLS.HOLY_SHOCK_CAST.id} /> was available will now be highlighted in the timeline.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2019-01-05'),
    changes: <>Fixed a bug in the analysis of <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} /> that caused the active damage reduction to sometimes not properly be accounted for.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-12-13'),
    changes: <>Fixed a bug in the analysis of <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} /> that caused the passive damage reduction to not be properly accounted for.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-11-26'),
    changes: <>Improved accuracy of mastery effectiveness by using an advanced algorithm to determine if a player would actually have had an increased chance of survival from a potential increase in mastery effectiveness.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-11-10'),
    changes: <>Fixed a crash when using <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} />.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-10-10'),
    changes: <>Fixed a rare bug in the <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} /> damage reduction analysis where an immunity at the exact right time could throw off the analysis.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-16'),
    changes: <>Added <SpellLink id={SPELLS.BEACON_OF_LIGHT_HEAL.id} /> healing lost from missing beacons as well as beacon line of sighting.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-15'),
    changes: <>Improved <SpellLink id={SPELLS.BEACON_OF_LIGHT_HEAL.id} /> transfer tracking accuracy when line of sighting beacon targets.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-15'),
    changes: <>Fixed a bug in <SpellLink id={SPELLS.BEACON_OF_LIGHT_HEAL.id} /> transfer tracking where line of sighting a beacon targets could lead to inaccurate results.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-15'),
    changes: <>Added a <SpellLink id={SPELLS.BEACON_OF_LIGHT_HEAL.id} /> transfer sources breakdown tab.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-15'),
    changes: <>Fixed a bug in the analysis of <SpellLink id={SPELLS.HOLY_AVENGER_TALENT.id} /> where beacon healing was included twice.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-11'),
    changes: 'Shuffled the checklist to put higher importance things higher in the list.',
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-08-03'),
    changes: <><SpellLink id={SPELLS.DEVOTION_AURA_TALENT.id} /> analysis will no longer crash when the combatlog is corrupt.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-24'),
    changes: <>Improved the accuracy of the <SpellLink id={SPELLS.DEVOTION_AURA_TALENT.id} /> statistic by correctly scaling the passive via the new formula (more info).</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-24'),
    changes: <>Improved the accuracy of the <SpellLink id={SPELLS.DEVOTION_AURA_TALENT.id} /> statistic by excluding false positives such as <SpellLink id={SPELLS.STAGGER.id} /> from the <SpellLink id={SPELLS.AURA_MASTERY.id} /> effect. Added a link to WCL to view the details of Aura Mastery.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-22'),
    changes: 'Changed the "FoL/HL on beacons" statistic to "Direct beacon healing", so it now includes all beacon transfering spells.',
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-22'),
    changes: <>Reworked the <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} /> analyzer to get a much more accurate result. The old method had a lot of false positive damage included due to bugs in the logs coming out of the game.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-22'),
    changes: <>Changed the <SpellLink id={SPELLS.DIVINE_PURPOSE_TALENT_HOLY.id} /> analyzer to include back to back procs.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-21'),
    changes: <>Healing increases such as Ilterendi, Crown Jewel of Silvermoon now correctly include boosted healing caused by <SpellLink id={SPELLS.AVENGING_CRUSADER_TALENT.id} />.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-19'),
    changes: <>Added <SpellLink id={SPELLS.AVENGING_CRUSADER_TALENT.id} /> to the cooldown tracker.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-19'),
    changes: 'Removed Tyr\'s Deliverance.',
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-18'),
    changes: <>Added an accurate <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} /> stat for the reworked version.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-03'),
    changes: <>Fixed a bug where the mana reduction by <SpellLink id={SPELLS.DIVINE_PURPOSE_TALENT_HOLY.id} /> of <SpellLink id={SPELLS.HOLY_SHOCK_CAST.id} /> and <SpellLink id={SPELLS.LIGHT_OF_DAWN_CAST.id} /> was not correctly accounted for in the cooldowns tab.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-06-29'),
    changes: <>Updated the crit bonus for <SpellLink id={SPELLS.HOLY_SHOCK_CAST.id} /> to 30% (up from 25%).</>,
    contributors: [Zerotorescue],
  },
];
<commit_msg>Moved to holy paladin change log.<commit_after>import React from 'react';

import { Zerotorescue } from 'CONTRIBUTORS';
import SPELLS from 'common/SPELLS';
import SpellLink from 'common/SpellLink';

export default [
  {
    date: new Date('2019-03-19'),
    changes: 'Added statistic tracker for Radiant Incandescence showing healing, crit rate, damage and healing transfered to beacons.',
    contributors: [HolySchmidt],
  },
  {
    date: new Date('2019-02-24'),
    changes: <><SpellLink id={SPELLS.GLIMMER_OF_LIGHT.id} /> has been marked as scaling with Mastery and Haste and should now affect the stat values.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2019-01-30'),
    changes: <><SpellLink id={SPELLS.GLIMMER_OF_LIGHT.id} /> has been added to the list of spells that beacon transfer (50% efficiency).</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2019-01-05'),
    changes: <><SpellLink id={SPELLS.LIGHT_OF_THE_MARTYR.id} /> that did negative healing are now highlighted in the timeline.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2019-01-05'),
    changes: <><SpellLink id={SPELLS.LIGHT_OF_THE_MARTYR.id} /> cast while <SpellLink id={SPELLS.HOLY_SHOCK_CAST.id} /> was available will now be highlighted in the timeline.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2019-01-05'),
    changes: <>Fixed a bug in the analysis of <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} /> that caused the active damage reduction to sometimes not properly be accounted for.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-12-13'),
    changes: <>Fixed a bug in the analysis of <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} /> that caused the passive damage reduction to not be properly accounted for.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-11-26'),
    changes: <>Improved accuracy of mastery effectiveness by using an advanced algorithm to determine if a player would actually have had an increased chance of survival from a potential increase in mastery effectiveness.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-11-10'),
    changes: <>Fixed a crash when using <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} />.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-10-10'),
    changes: <>Fixed a rare bug in the <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} /> damage reduction analysis where an immunity at the exact right time could throw off the analysis.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-16'),
    changes: <>Added <SpellLink id={SPELLS.BEACON_OF_LIGHT_HEAL.id} /> healing lost from missing beacons as well as beacon line of sighting.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-15'),
    changes: <>Improved <SpellLink id={SPELLS.BEACON_OF_LIGHT_HEAL.id} /> transfer tracking accuracy when line of sighting beacon targets.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-15'),
    changes: <>Fixed a bug in <SpellLink id={SPELLS.BEACON_OF_LIGHT_HEAL.id} /> transfer tracking where line of sighting a beacon targets could lead to inaccurate results.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-15'),
    changes: <>Added a <SpellLink id={SPELLS.BEACON_OF_LIGHT_HEAL.id} /> transfer sources breakdown tab.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-15'),
    changes: <>Fixed a bug in the analysis of <SpellLink id={SPELLS.HOLY_AVENGER_TALENT.id} /> where beacon healing was included twice.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-09-11'),
    changes: 'Shuffled the checklist to put higher importance things higher in the list.',
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-08-03'),
    changes: <><SpellLink id={SPELLS.DEVOTION_AURA_TALENT.id} /> analysis will no longer crash when the combatlog is corrupt.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-24'),
    changes: <>Improved the accuracy of the <SpellLink id={SPELLS.DEVOTION_AURA_TALENT.id} /> statistic by correctly scaling the passive via the new formula (more info).</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-24'),
    changes: <>Improved the accuracy of the <SpellLink id={SPELLS.DEVOTION_AURA_TALENT.id} /> statistic by excluding false positives such as <SpellLink id={SPELLS.STAGGER.id} /> from the <SpellLink id={SPELLS.AURA_MASTERY.id} /> effect. Added a link to WCL to view the details of Aura Mastery.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-22'),
    changes: 'Changed the "FoL/HL on beacons" statistic to "Direct beacon healing", so it now includes all beacon transfering spells.',
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-22'),
    changes: <>Reworked the <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} /> analyzer to get a much more accurate result. The old method had a lot of false positive damage included due to bugs in the logs coming out of the game.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-22'),
    changes: <>Changed the <SpellLink id={SPELLS.DIVINE_PURPOSE_TALENT_HOLY.id} /> analyzer to include back to back procs.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-21'),
    changes: <>Healing increases such as Ilterendi, Crown Jewel of Silvermoon now correctly include boosted healing caused by <SpellLink id={SPELLS.AVENGING_CRUSADER_TALENT.id} />.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-19'),
    changes: <>Added <SpellLink id={SPELLS.AVENGING_CRUSADER_TALENT.id} /> to the cooldown tracker.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-19'),
    changes: 'Removed Tyr\'s Deliverance.',
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-18'),
    changes: <>Added an accurate <SpellLink id={SPELLS.AURA_OF_SACRIFICE_TALENT.id} /> stat for the reworked version.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-07-03'),
    changes: <>Fixed a bug where the mana reduction by <SpellLink id={SPELLS.DIVINE_PURPOSE_TALENT_HOLY.id} /> of <SpellLink id={SPELLS.HOLY_SHOCK_CAST.id} /> and <SpellLink id={SPELLS.LIGHT_OF_DAWN_CAST.id} /> was not correctly accounted for in the cooldowns tab.</>,
    contributors: [Zerotorescue],
  },
  {
    date: new Date('2018-06-29'),
    changes: <>Updated the crit bonus for <SpellLink id={SPELLS.HOLY_SHOCK_CAST.id} /> to 30% (up from 25%).</>,
    contributors: [Zerotorescue],
  },
];
<|endoftext|>


<commit_before>import Camera from 'Camera';

class Game {
  desiredFPS;
  desiredInterval;
  lastUpdate;
  currentState;

  pressedKeys = [];
  preventedKeys = [];

  degToRad = Math.PI / 180;
  keyCodeToString = [];

  pressedButtons = [];
  mouse = {};

  convertMouseButtonToString = ['left', 'center', 'right'];

  pauseKey = 'p';
  canPauseOrResume = true;
  gamePaused = false;

  currentFont = '10px serif';

  windowFocused;

  constructor () {
    this.keyCodeToString[8] = 'backspace';
    this.keyCodeToString[9] = 'tab';
    this.keyCodeToString[13] = 'return';
    this.keyCodeToString[16] = 'shift';
    this.keyCodeToString[17] = 'ctrl';
    this.keyCodeToString[18] = 'alt';
    this.keyCodeToString[19] = 'pause';
    this.keyCodeToString[20] = 'capslock';
    this.keyCodeToString[27] = 'escape';
    this.keyCodeToString[32] = 'space';
    this.keyCodeToString[33] = 'pageup';
    this.keyCodeToString[34] = 'pagedown';
    this.keyCodeToString[35] = 'end';
    this.keyCodeToString[36] = 'home';
    this.keyCodeToString[37] = 'left';
    this.keyCodeToString[38] = 'up';
    this.keyCodeToString[39] = 'right';
    this.keyCodeToString[40] = 'down';
    this.keyCodeToString[45] = 'insert';
    this.keyCodeToString[46] = 'delete';

    this.keyCodeToString[91] = 'leftwindowkey';
    this.keyCodeToString[92] = 'rightwindowkey';
    this.keyCodeToString[93] = 'selectkey';
    this.keyCodeToString[106] = 'multiply';
    this.keyCodeToString[107] = 'add';
    this.keyCodeToString[109] = 'subtract';
    this.keyCodeToString[110] = 'decimalpoint';
    this.keyCodeToString[111] = 'divide';

    this.keyCodeToString[144] = 'numlock';
    this.keyCodeToString[145] = 'scrollock';
    this.keyCodeToString[186] = 'semicolon';
    this.keyCodeToString[187] = 'equalsign';
    this.keyCodeToString[188] = 'comma';
    this.keyCodeToString[189] = 'dash';
    this.keyCodeToString[190] = 'period';
    this.keyCodeToString[191] = 'forwardslash';
    this.keyCodeToString[192] = 'graveaccent';
    this.keyCodeToString[219] = 'openbracket';
    this.keyCodeToString[220] = 'backslash';
    this.keyCodeToString[221] = 'closebracket';
    this.keyCodeToString[222] = 'singlequote';

    var numpadKeys = ['numpad1', 'numpad2', 'numpad3', 'numpad4', 'numpad5',
                      'numpad6', 'numpad7', 'numpad8', 'numpad9'];

    var numbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
    var letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
                   'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
                   'u', 'v', 'w', 'x', 'y', 'z'];

    _.each(numbers, (el, index) => this.keyCodeToString[48 + index] = el);
    _.each(letters, (el, index) => this.keyCodeToString[65 + index] = el);
    _.each(numpadKeys, (el, index) => this.keyCodeToString[48 + index] = el);

    window.addEventListener('keydown', this.handleKeyDown.bind(this));
    window.addEventListener('keyup', this.handleKeyUp.bind(this));

    window.addEventListener('mousedown', this.handleMouseDown.bind(this), false);
    window.addEventListener('mouseup', this.handleMouseUp.bind(this), false);
    window.addEventListener('mousemove', this.handleMouseMove.bind(this), false);

    window.addEventListener('touchstart', this.handleTouchStart.bind(this), false);
    window.addEventListener('touchmove', this.handleTouchMove.bind(this), false);
    window.addEventListener('touchend', this.handleTouchEnd.bind(this), false);

    this.windowFocused = true;
    window.addEventListener('focus', this.handleFocus.bind(this), false);
    window.addEventListener('blur', this.handleBlur.bind(this), false);
  }

  tick() {
    if (this.windowFocused) {
      window.requestAnimationFrame(this.tick.bind(this));

      var currentTime = Date.now();
      var elapsed = currentTime - this.lastUpdate;

      if (this.desiredFPS) {
        if (elapsed > this.desiredInterval) {
          this.lastUpdate = currentTime - (elapsed % this.desiredInterval);

          this.currentState.dt = elapsed * 0.001;
          this.currentState.update();
          this.currentState.draw();
        }
      } else {
        this.lastUpdate = currentTime;

        this.currentState.dt = elapsed * 0.001;
        this.currentState.update();
        this.currentState.draw();
      }
    }
  }

  init() {
    this.lastUpdate = Date.now();

    if (this.desiredFPS) {
      this.desiredInterval = 1000 / this.desiredFPS;
    }

    window.requestAnimationFrame(this.tick.bind(this));
  }

  handleFocus(e) {
    this.windowFocused = true;
    window.requestAnimationFrame(this.tick.bind(this));
  }

  handleBlur(e) {
    this.windowFocused = false;
  }

  handleKeyDown(e) {
    var event = (e) ? e : window.event;
    var name = this.keyCodeToString[event.keyCode];
    this.pressedKeys[name] = true;

    if (this.preventedKeys.indexOf(name) > -1) {
      e.preventDefault();
    }
  }

  handleKeyUp(e) {
    var event = (e) ? e : window.event;
    var name = this.keyCodeToString[event.keyCode];
    this.pressedKeys[name] = false;

    if (this.preventedKeys.indexOf(name) > -1) {
      e.preventDefault();
    }
  }

  handleTouchStart(e) {
    var event = (e) ? e : window.event;

    mouse.x = event.targetTouches[0].pageX;
    mouse.y = event.targetTouches[0].pageY;

    this.pressedButtons['left'] = true;
  }

  handleTouchEnd(e) {
    var event = (e) ? e : window.event;

    this.pressedButtons['left'] = false;
  }

  handleMouseDown(e) {
    var event = (e) ? e : window.event;
    var humanName = this.convertMouseButtonToString[event.button];

    this.pressedButtons[humanName] = true;
  }

  handleMouseUp(e) {
    var event = (e) ? e : window.event;
    var humanName = this.convertMouseButtonToString[event.button];

    this.pressedButtons[humanName] = false;
  }

  handleMouseMove(e) {
    if (e.offsetX) {
      this.mouse.x = e.offsetX;
      this.mouse.y = e.offsetY;
    } else if (e.layerX) {
      this.mouse.x = e.layerX;
      this.mouse.y = e.layerY;
    }

    if (this.currentState !== null) {
      this.mouse.x += this.currentState.camera.x;
      this.mouse.y += this.currentState.camera.y;
    }
  }

  handleTouchMove(e) {
    var event = (e) ? e : window.event;
    e.stopPropagation();
    e.preventDefault();

    /* Tanslate to mouse event */
    var clickEvent = document.createEvent('MouseEvent');
    clickEvent.initMouseEvent('mousemove', true, true, window, e.detail,
                              e.touches[0].screenX, e.touches[0].screenY,
                              e.touches[0].clientX, e.touches[0].clientY,
                              false, false, false, false, 0, null);

    window.dispatchEvent(clickEvent);
  }

  setup(options) {
    /* Load the canvas */
    this.canvas = document.getElementById('canvas');

    if (options.desiredFPS) {
      this.desiredFPS = options.desiredFPS;
    }

    if (options.preventedKeys) {
      this.preventedKeys = options.preventedKeys;
    }

    if (typeof this.canvas !== 'undefined') {
      this.canvas.width = options.width;
      this.canvas.height = options.height;

      this.context = this.canvas.getContext('2d');

      this.context.width = options.width;
      this.context.height = options.height;
    }

    this.switchState(options.firstState);

    return this;
  }

  drawCircle(x, y, radius, color) {
    this.context.fillStyle = color;
    this.context.beginPath();
    this.context.arc(x, y, radius, 0, Math.PI * 2, false);
    this.context.fill();
  }

  drawRectangle(x, y, width, height, color) {
    if (color) {
      this.context.fillStyle = color;
    }

    this.context.fillRect(x, y, width, height);
  }

  drawLine(x1, y1, x2, y2, color) {
    if (color) {
      this.context.strokeStyle = color;
    }

    this.context.beginPath();
    this.context.moveTo(x1, y1);
    this.context.lineTo(x2, y2);
    this.context.stroke();
  }

  drawString(text, x, y, color, alignment) {
    if (!alignment) {
      this.context.textAlign = 'left';
    } else {
      this.context.textAlign = alignment;
    }

    this.context.textBaseline = 'top';
    this.context.font = this.currentFont;
    this.context.fillStyle = color;
    this.context.fillText(text, x, y);
  }

  clearCanvas(backgroundColor) {
    this.context.clearRect(0, 0, this.context.width, this.context.height);

    if (backgroundColor) {
      this.drawRectangle(0, 0, this.context.width, this.context.height, backgroundColor);
    }
  }

  isOutsideOfScreen(x, y) {
    return x < 0 || x > this.context.width || y < 0 || y > this.context.height;
  }

  getpressedKeys() {
    return this.pressedKeys;
  }

  getPressedButtons() {
    return this.pressedButtons;
  }

  isDown(name) {
    if (this.pressedKeys[name]) {
      return true;
    }

    return false;
  }

  isMouseDown(name) {
    if (this.pressedButtons[name]) {
      return true;
    }

    return false;
  }

  switchState(newState) {
    if (!this.currentState) {
      this.currentState = newState;
      this.init();
    }

    if (!newState.camera) {
      newState.camera = new Camera(0, 0, this.context.width, this.context.height);
    }

    newState.setup();

    this.currentState = newState;

    return this.currentState;
  }
}

module.exports = Game;
<commit_msg>Implements changeFontSize method in pentagine's Game class.<commit_after>import Camera from 'Camera';

class Game {
  desiredFPS;
  desiredInterval;
  lastUpdate;
  currentState;

  pressedKeys = [];
  preventedKeys = [];

  degToRad = Math.PI / 180;
  keyCodeToString = [];

  pressedButtons = [];
  mouse = {};

  convertMouseButtonToString = ['left', 'center', 'right'];

  pauseKey = 'p';
  canPauseOrResume = true;
  gamePaused = false;

  currentFont = '10px serif';

  windowFocused;

  constructor () {
    this.keyCodeToString[8] = 'backspace';
    this.keyCodeToString[9] = 'tab';
    this.keyCodeToString[13] = 'return';
    this.keyCodeToString[16] = 'shift';
    this.keyCodeToString[17] = 'ctrl';
    this.keyCodeToString[18] = 'alt';
    this.keyCodeToString[19] = 'pause';
    this.keyCodeToString[20] = 'capslock';
    this.keyCodeToString[27] = 'escape';
    this.keyCodeToString[32] = 'space';
    this.keyCodeToString[33] = 'pageup';
    this.keyCodeToString[34] = 'pagedown';
    this.keyCodeToString[35] = 'end';
    this.keyCodeToString[36] = 'home';
    this.keyCodeToString[37] = 'left';
    this.keyCodeToString[38] = 'up';
    this.keyCodeToString[39] = 'right';
    this.keyCodeToString[40] = 'down';
    this.keyCodeToString[45] = 'insert';
    this.keyCodeToString[46] = 'delete';

    this.keyCodeToString[91] = 'leftwindowkey';
    this.keyCodeToString[92] = 'rightwindowkey';
    this.keyCodeToString[93] = 'selectkey';
    this.keyCodeToString[106] = 'multiply';
    this.keyCodeToString[107] = 'add';
    this.keyCodeToString[109] = 'subtract';
    this.keyCodeToString[110] = 'decimalpoint';
    this.keyCodeToString[111] = 'divide';

    this.keyCodeToString[144] = 'numlock';
    this.keyCodeToString[145] = 'scrollock';
    this.keyCodeToString[186] = 'semicolon';
    this.keyCodeToString[187] = 'equalsign';
    this.keyCodeToString[188] = 'comma';
    this.keyCodeToString[189] = 'dash';
    this.keyCodeToString[190] = 'period';
    this.keyCodeToString[191] = 'forwardslash';
    this.keyCodeToString[192] = 'graveaccent';
    this.keyCodeToString[219] = 'openbracket';
    this.keyCodeToString[220] = 'backslash';
    this.keyCodeToString[221] = 'closebracket';
    this.keyCodeToString[222] = 'singlequote';

    var numpadKeys = ['numpad1', 'numpad2', 'numpad3', 'numpad4', 'numpad5',
                      'numpad6', 'numpad7', 'numpad8', 'numpad9'];

    var numbers = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
    var letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
                   'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
                   'u', 'v', 'w', 'x', 'y', 'z'];

    _.each(numbers, (el, index) => this.keyCodeToString[48 + index] = el);
    _.each(letters, (el, index) => this.keyCodeToString[65 + index] = el);
    _.each(numpadKeys, (el, index) => this.keyCodeToString[48 + index] = el);

    window.addEventListener('keydown', this.handleKeyDown.bind(this));
    window.addEventListener('keyup', this.handleKeyUp.bind(this));

    window.addEventListener('mousedown', this.handleMouseDown.bind(this), false);
    window.addEventListener('mouseup', this.handleMouseUp.bind(this), false);
    window.addEventListener('mousemove', this.handleMouseMove.bind(this), false);

    window.addEventListener('touchstart', this.handleTouchStart.bind(this), false);
    window.addEventListener('touchmove', this.handleTouchMove.bind(this), false);
    window.addEventListener('touchend', this.handleTouchEnd.bind(this), false);

    this.windowFocused = true;
    window.addEventListener('focus', this.handleFocus.bind(this), false);
    window.addEventListener('blur', this.handleBlur.bind(this), false);
  }

  tick() {
    if (this.windowFocused) {
      window.requestAnimationFrame(this.tick.bind(this));

      var currentTime = Date.now();
      var elapsed = currentTime - this.lastUpdate;

      if (this.desiredFPS) {
        if (elapsed > this.desiredInterval) {
          this.lastUpdate = currentTime - (elapsed % this.desiredInterval);

          this.currentState.dt = elapsed * 0.001;
          this.currentState.update();
          this.currentState.draw();
        }
      } else {
        this.lastUpdate = currentTime;

        this.currentState.dt = elapsed * 0.001;
        this.currentState.update();
        this.currentState.draw();
      }
    }
  }

  init() {
    this.lastUpdate = Date.now();

    if (this.desiredFPS) {
      this.desiredInterval = 1000 / this.desiredFPS;
    }

    window.requestAnimationFrame(this.tick.bind(this));
  }

  handleFocus(e) {
    this.windowFocused = true;
    window.requestAnimationFrame(this.tick.bind(this));
  }

  handleBlur(e) {
    this.windowFocused = false;
  }

  handleKeyDown(e) {
    var event = (e) ? e : window.event;
    var name = this.keyCodeToString[event.keyCode];
    this.pressedKeys[name] = true;

    if (this.preventedKeys.indexOf(name) > -1) {
      e.preventDefault();
    }
  }

  handleKeyUp(e) {
    var event = (e) ? e : window.event;
    var name = this.keyCodeToString[event.keyCode];
    this.pressedKeys[name] = false;

    if (this.preventedKeys.indexOf(name) > -1) {
      e.preventDefault();
    }
  }

  handleTouchStart(e) {
    var event = (e) ? e : window.event;

    mouse.x = event.targetTouches[0].pageX;
    mouse.y = event.targetTouches[0].pageY;

    this.pressedButtons['left'] = true;
  }

  handleTouchEnd(e) {
    var event = (e) ? e : window.event;

    this.pressedButtons['left'] = false;
  }

  handleMouseDown(e) {
    var event = (e) ? e : window.event;
    var humanName = this.convertMouseButtonToString[event.button];

    this.pressedButtons[humanName] = true;
  }

  handleMouseUp(e) {
    var event = (e) ? e : window.event;
    var humanName = this.convertMouseButtonToString[event.button];

    this.pressedButtons[humanName] = false;
  }

  handleMouseMove(e) {
    if (e.offsetX) {
      this.mouse.x = e.offsetX;
      this.mouse.y = e.offsetY;
    } else if (e.layerX) {
      this.mouse.x = e.layerX;
      this.mouse.y = e.layerY;
    }

    if (this.currentState !== null) {
      this.mouse.x += this.currentState.camera.x;
      this.mouse.y += this.currentState.camera.y;
    }
  }

  handleTouchMove(e) {
    var event = (e) ? e : window.event;
    e.stopPropagation();
    e.preventDefault();

    /* Tanslate to mouse event */
    var clickEvent = document.createEvent('MouseEvent');
    clickEvent.initMouseEvent('mousemove', true, true, window, e.detail,
                              e.touches[0].screenX, e.touches[0].screenY,
                              e.touches[0].clientX, e.touches[0].clientY,
                              false, false, false, false, 0, null);

    window.dispatchEvent(clickEvent);
  }

  setup(options) {
    /* Load the canvas */
    this.canvas = document.getElementById('canvas');

    if (options.desiredFPS) {
      this.desiredFPS = options.desiredFPS;
    }

    if (options.preventedKeys) {
      this.preventedKeys = options.preventedKeys;
    }

    if (typeof this.canvas !== 'undefined') {
      this.canvas.width = options.width;
      this.canvas.height = options.height;

      this.context = this.canvas.getContext('2d');

      this.context.width = options.width;
      this.context.height = options.height;
    }

    this.switchState(options.firstState);

    return this;
  }

  drawCircle(x, y, radius, color) {
    this.context.fillStyle = color;
    this.context.beginPath();
    this.context.arc(x, y, radius, 0, Math.PI * 2, false);
    this.context.fill();
  }

  drawRectangle(x, y, width, height, color) {
    if (color) {
      this.context.fillStyle = color;
    }

    this.context.fillRect(x, y, width, height);
  }

  drawLine(x1, y1, x2, y2, color) {
    if (color) {
      this.context.strokeStyle = color;
    }

    this.context.beginPath();
    this.context.moveTo(x1, y1);
    this.context.lineTo(x2, y2);
    this.context.stroke();
  }

  drawString(text, x, y, color, alignment) {
    if (!alignment) {
      this.context.textAlign = 'left';
    } else {
      this.context.textAlign = alignment;
    }

    this.context.textBaseline = 'top';
    this.context.font = this.currentFont;
    this.context.fillStyle = color;
    this.context.fillText(text, x, y);
  }

  clearCanvas(backgroundColor) {
    this.context.clearRect(0, 0, this.context.width, this.context.height);

    if (backgroundColor) {
      this.drawRectangle(0, 0, this.context.width, this.context.height, backgroundColor);
    }
  }

  isOutsideOfScreen(x, y) {
    return x < 0 || x > this.context.width || y < 0 || y > this.context.height;
  }

  getpressedKeys() {
    return this.pressedKeys;
  }

  getPressedButtons() {
    return this.pressedButtons;
  }

  isDown(name) {
    if (this.pressedKeys[name]) {
      return true;
    }

    return false;
  }

  isMouseDown(name) {
    if (this.pressedButtons[name]) {
      return true;
    }

    return false;
  }

  changeFontSize(newSize) {
    var splittedFont = this.currentFont.split(' ');

    if (splittedFont.length === 3) {
      this.currentFont = splittedFont[0] + ' ' + newSize + 'px ' + splittedFont[2];
    } else {
      this.currentFont = newSize.toString() + 'px ' + splittedFont[1];
    }
  }

  switchState(newState) {
    if (!this.currentState) {
      this.currentState = newState;
      this.init();
    }

    if (!newState.camera) {
      newState.camera = new Camera(0, 0, this.context.width, this.context.height);
    }

    newState.setup();

    this.currentState = newState;

    return this.currentState;
  }
}

module.exports = Game;
<|endoftext|>


<commit_before>import Renderer from './Renderer';
import Bounds from './Bounds';
import marks from './marks/index';

import inherits from './util/inherits';
import {clear} from './util/dom';
import Canvas from './util/canvas/canvas';
import resize from './util/canvas/resize';

export default function CanvasRenderer(loadConfig) {
  Renderer.call(this, loadConfig);
}

var prototype = inherits(CanvasRenderer, Renderer),
    base = Renderer.prototype,
    tempBounds = new Bounds();

prototype.initialize = function(el, width, height, origin) {
  this._canvas = Canvas(width, height);
  if (el) {
    clear(el, 0).appendChild(this._canvas);
    this._canvas.setAttribute('class', 'marks');
  }
  return base.initialize.call(this, el, width, height, origin);
};

prototype.resize = function(width, height, origin) {
  base.resize.call(this, width, height, origin);
  resize(this._canvas, this._width, this._height, this._origin);
  return this;
};

prototype.canvas = function() {
  return this._canvas;
};

prototype.context = function() {
  return this._canvas ? this._canvas.getContext('2d') : null;
};

function clipToBounds(g, items) {
  if (!items) return null;

  var b = new Bounds(), i, n, item, mark, group;
  for (i=0, n=items.length; i<n; ++i) {
    item = items[i];
    mark = item.mark;
    group = mark.group;
    item = marks[mark.marktype].nested ? mark : item;
    b.union(translate(item.bounds, group));
    if (item['bounds:prev']) {
      b.union(translate(item['bounds:prev'], group));
    }
  }
  b.round();

  g.beginPath();
  g.rect(b.x1, b.y1, b.width(), b.height());
  g.clip();

  return b;
}

function translate(bounds, group) {
  if (group == null) return bounds;
  var b = tempBounds.clear().union(bounds);
  for (; group != null; group = group.mark.group) {
    b.translate(group.x || 0, group.y || 0);
  }
  return b;
}

prototype._render = function(scene, items) {
  var g = this.context(),
      o = this._origin,
      w = this._width,
      h = this._height,
      b;

  // setup
  g.save();
  b = clipToBounds(g, items);
  this.clear(-o[0], -o[1], w, h);

  // render
  this.draw(g, scene, b);

  // takedown
  g.restore();

  return this;
};

prototype.draw = function(ctx, scene, bounds) {
  var mark = marks[scene.marktype];
  mark.draw.call(this, ctx, scene, bounds);
};

prototype.clear = function(x, y, w, h) {
  var g = this.context();
  g.clearRect(x, y, w, h);
  if (this._bgcolor != null) {
    g.fillStyle = this._bgcolor;
    g.fillRect(x, y, w, h);
  }
};
<commit_msg>Initialize small canvas.<commit_after>import Renderer from './Renderer';
import Bounds from './Bounds';
import marks from './marks/index';

import inherits from './util/inherits';
import {clear} from './util/dom';
import Canvas from './util/canvas/canvas';
import resize from './util/canvas/resize';

export default function CanvasRenderer(loadConfig) {
  Renderer.call(this, loadConfig);
}

var prototype = inherits(CanvasRenderer, Renderer),
    base = Renderer.prototype,
    tempBounds = new Bounds();

prototype.initialize = function(el, width, height, origin) {
  this._canvas = Canvas(1, 1); // instantiate a small canvas
  if (el) {
    clear(el, 0).appendChild(this._canvas);
    this._canvas.setAttribute('class', 'marks');
  }
  // this method will invoke resize to size the canvas appropriately
  return base.initialize.call(this, el, width, height, origin);
};

prototype.resize = function(width, height, origin) {
  base.resize.call(this, width, height, origin);
  resize(this._canvas, this._width, this._height, this._origin);
  return this;
};

prototype.canvas = function() {
  return this._canvas;
};

prototype.context = function() {
  return this._canvas ? this._canvas.getContext('2d') : null;
};

function clipToBounds(g, items) {
  if (!items) return null;

  var b = new Bounds(), i, n, item, mark, group;
  for (i=0, n=items.length; i<n; ++i) {
    item = items[i];
    mark = item.mark;
    group = mark.group;
    item = marks[mark.marktype].nested ? mark : item;
    b.union(translate(item.bounds, group));
    if (item['bounds:prev']) {
      b.union(translate(item['bounds:prev'], group));
    }
  }
  b.round();

  g.beginPath();
  g.rect(b.x1, b.y1, b.width(), b.height());
  g.clip();

  return b;
}

function translate(bounds, group) {
  if (group == null) return bounds;
  var b = tempBounds.clear().union(bounds);
  for (; group != null; group = group.mark.group) {
    b.translate(group.x || 0, group.y || 0);
  }
  return b;
}

prototype._render = function(scene, items) {
  var g = this.context(),
      o = this._origin,
      w = this._width,
      h = this._height,
      b;

  // setup
  g.save();
  b = clipToBounds(g, items);
  this.clear(-o[0], -o[1], w, h);

  // render
  this.draw(g, scene, b);

  // takedown
  g.restore();

  return this;
};

prototype.draw = function(ctx, scene, bounds) {
  var mark = marks[scene.marktype];
  mark.draw.call(this, ctx, scene, bounds);
};

prototype.clear = function(x, y, w, h) {
  var g = this.context();
  g.clearRect(x, y, w, h);
  if (this._bgcolor != null) {
    g.fillStyle = this._bgcolor;
    g.fillRect(x, y, w, h);
  }
};
<|endoftext|>