diff --git a/css/styles.css b/css/styles.css index ea54e91..1f0a99f 100644 --- a/css/styles.css +++ b/css/styles.css @@ -1,20 +1,420 @@ -#turnipTable th, #turnipTable td { - padding: 5px; +@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&family=Varela+Round&display=swap'); + +/* - Global Styles - */ + +html { + font-size: 14px; + background: #DEF2D9; + background-image: + radial-gradient(#fff 20%, transparent 0), + radial-gradient(#fff 20%, transparent 0); + background-size: 30px 30px; + background-position: 0 0, 15px 15px; + + /* background: #FFFAE5; */ +} + +body { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-family: 'Varela Round', sans-serif; + padding-bottom: 20vh; +} + +h1 { + text-align: center; + font-size: 1.8rem; +} + +h2 { + text-align: center; + font-size: 1.6rem; +} + +.nook-phone { + width: 100%; + box-sizing: border-box; + margin: 16px auto; + border-radius: 40px; + padding: 16px 0px; + padding-bottom: 32px; + background: #F5F8FF; + color: #686868; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.08); +} + +.nook-phone-center { + background: white; + display: flex; + flex-direction: column; + align-items: center; +} + + +.dialog-box { + background: #FFFAE5; + box-sizing: border-box; + padding: 16px 24px; + margin: 32px auto; + position: relative; + border-radius: 40px; + max-width: 800px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.08); +} + +.dialog-box p { + font-family: 'Raleway', sans-serif; + font-weight: 800; + font-size: 1.2rem; + color: #837865; + letter-spacing: 0.2px; + line-height: 1.8rem; +} + +.dialog-box b, +.dialog-box a { + color: #0AB5CD; + transition: 0.2s all; +} + +.dialog-box i { + font-style: normal; + color: #aaa; +} + +.dialog-box a:hover { + color: #5ECEDB +} + +.dialog-box .dialog-box__name { + position: absolute; + left: 16px; + top: -28px; + font-size: 1.1rem; + color: #BA3B1F; + padding: 4px 24px; + background: #FF9A40; + border-radius: 40px; +} + +.input__form { + background: white; + display: flex; + flex-direction: column; + padding: 32px; + align-items: center; +} + +.form__row { + display: flex; + flex-wrap: wrap; + margin-bottom: 32px; + justify-content: center; + align-items: center; +} + +.form__row h6 { + width: 100%; + display: block; + font-weight: 800; + font-size: 1.5rem; + margin: 16px auto; + color: #845E44; + text-align: center; +} + +.form__flex-wrap { + margin-top: 16px; + display: flex; + flex-wrap: wrap; + max-width: 100%; + justify-content: center; +} + +.input__group { + display: flex; + flex-direction: column; + margin: 8px; + align-items: center; +} + +.input__group label { + font-size: 1.2rem; + font-weight: bold; + margin-bottom: 8px; + opacity: 0.7; + text-align: center; +} + +.form__flex-wrap .input__group label { + + margin-left: 0px; + margin-bottom: 8px; +} + +.input__form i { + text-align: center; + display: block; + font-style: normal; + color: #aaa; + font-size: 0.9rem; + margin: 8px auto; +} + +.input__form>.form__row input { + margin: 0px auto; +} + +input { + border: 0px solid white; + border-radius: 40px; + padding: 16px 24px; + font-size: 2rem; + font-family: inherit; + color: inherit; + font-weight: bold; + transition: 0.2s all; + margin: 8px 0px; +} + +input[type=number]:placeholder-shown { + background: #f3f3f3; +} + +input[type=number]:not(:placeholder-shown) { + background: transparent; + color: #0AB5CD; +} + +input[type=number]:placeholder-shown:hover { + cursor: pointer; + transform: scale(1.1); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.05), 0 3px 6px rgba(0, 0, 0, 0.09); +} + +input[type=number]:focus { + outline: none; + transform: scale(1.1); + background: white; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.05), 0 3px 6px rgba(0, 0, 0, 0.09); +} + +input[type=number]:focus::placeholder { + opacity: 0; +} + +input[type=number] { + width: 60px; + text-align: center; +} + +input[type=number]:disabled { + background: inherit; } -#turnipTable thead tr:first-child { - border: none; +input[type=number]:disabled:hover { + box-shadow: none; + transform: none; + cursor: default; } -#turnipTable th { - text-align: center; +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; } -#turnipTable td:first-child { - white-space: nowrap; - text-align: left; +input[type=number] { + -moz-appearance: textfield; +} + +.input__radio-buttons { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin-top: 16px; +} + +.input__radio-buttons input[type=radio] { + display: none; +} + +.input__radio-buttons input[type="radio"]+label { + opacity: 1; + border: none; + border-radius: 40px; + background: #F3F3F3; + padding: 16px 24px; + font-size: 1.5rem; + font-family: inherit; + font-weight: bold; + transition: 0.2s all; + margin: 8px; + +} + +.input__radio-buttons input[type="radio"]:not(:checked)+label:hover { + cursor: pointer; + background: #F5F8FF; + transform: scale(1.1); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.05), 0 3px 6px rgba(0, 0, 0, 0.09); +} + +.input__radio-buttons input[type="radio"]:checked+label { + background: #0AB5CD; + color: #FFF; +} + +.reset-button { + font-family: inherit; + font-weight: bold; + color: #FFBABA; + padding: 8px 16px; + border-width: 0px; + border-radius: 40px; + background: transparent; + font-size: 1.2rem; + transition: 0.2s all; + position: relative; + margin: 32px auto; +} + +.reset-button:hover { + transform: scale(1.1); + cursor: pointer; + opacity: 1; + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.05), 0 3px 6px rgba(0, 0, 0, 0.09); +} + + +.table-wrapper { + display: inline-block; + max-width: 98%; + padding: 16px; + margin: 0px auto; + box-sizing: border-box; + overflow-x: auto; + border-radius: 2px; +} + + +.table-wrapper::-webkit-scrollbar { + height: 8px; + width: 5px; +} + +.table-wrapper::-webkit-scrollbar-track { + height: 8px; + width: 5px; + box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2); + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2); +} + +.table-wrapper::-webkit-scrollbar-thumb { + height: 8px; + width: 5px; + background: rgba(0, 0, 0, 0.2); + box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2); + -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.1); +} + +.table-wrapper::-webkit-scrollbar-thumb:window-inactive { + height: 8px; + width: 5px; + background: rgba(0, 0, 0, 0.2); +} + + +#turnipTable { + border-collapse: collapse; +} + +#turnipTable th div:nth-of-type(1) { + margin-bottom: 2px; +} + +#turnipTable th div:nth-of-type(2) { + display: flex; + justify-content: space-around; + opacity: 0.4; } #turnipTable td { - text-align: center; + max-width: 100px; + padding: 6px 4px; + text-align: center; + border-right: 1px solid rgba(0, 0, 0, 0.03); + border-bottom: 1px solid rgba(0, 0, 0, 0.15); +} + +#turnipTable tbody tr { + opacity: 0.8; +} + +#turnipTable tbody tr:hover { + cursor: default; + opacity: 1; +} + +#turnipTable .table-pattern { + white-space: normal; +} + + + + +.waves { + position: relative; + width: 100%; + height: 5vh; + margin-bottom: -7px; + /*Fix for safari gap*/ + max-height: 150px; } + + +/* Animation */ +.parallax>use { + animation: move-forever 25s cubic-bezier(.55, .5, .45, .5) infinite; +} + +.parallax>use:nth-child(1) { + animation-delay: -2s; + animation-duration: 7s; +} + +.parallax>use:nth-child(2) { + animation-delay: -3s; + animation-duration: 10s; +} + +.parallax>use:nth-child(3) { + animation-delay: -4s; + animation-duration: 13s; +} + +.parallax>use:nth-child(4) { + animation-delay: -5s; + animation-duration: 20s; +} + +@keyframes move-forever { + 0% { + transform: translate3d(-90px, 0, 0); + } + + 100% { + transform: translate3d(85px, 0, 0); + } +} + +/*Shrinking for mobile*/ +@media (max-width: 768px) { + .waves { + height: 40px; + min-height: 40px; + } +} \ No newline at end of file diff --git a/index.html b/index.html index 5b8dd42..e9cdcfd 100644 --- a/index.html +++ b/index.html @@ -1,22 +1,24 @@ + - - Animal Crossing Turnip Price Forecaster - + + Animal Crossing - Turnip Prophet + - - - + - - + diff --git a/js/predictions.js b/js/predictions.js index a253aa2..fc1917b 100644 --- a/js/predictions.js +++ b/js/predictions.js @@ -48,17 +48,14 @@ function maximum_rate_from_given_and_base(given_price, buy_price) { } function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_phase_1_len, high_phase_2_len, dec_phase_2_len, high_phase_3_len) { - /* + /* // PATTERN 0: high, decreasing, high, decreasing, high work = 2; - - // high phase 1 for (int i = 0; i < hiPhaseLen1; i++) { sellPrices[work++] = intceil(randfloat(0.9, 1.4) * basePrice); } - // decreasing phase 1 rate = randfloat(0.8, 0.6); for (int i = 0; i < decPhaseLen1; i++) @@ -67,13 +64,11 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph rate -= 0.04; rate -= randfloat(0, 0.06); } - // high phase 2 for (int i = 0; i < (hiPhaseLen2and3 - hiPhaseLen3); i++) { sellPrices[work++] = intceil(randfloat(0.9, 1.4) * basePrice); } - // decreasing phase 2 rate = randfloat(0.8, 0.6); for (int i = 0; i < decPhaseLen2; i++) @@ -82,7 +77,6 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph rate -= 0.04; rate -= randfloat(0, 0.06); } - // high phase 3 for (int i = 0; i < hiPhaseLen3; i++) { @@ -107,7 +101,7 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph min_pred = Math.floor(0.9 * buy_price); max_pred = Math.ceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred ) { + if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -130,7 +124,7 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred ) { + if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -154,7 +148,7 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph min_pred = Math.floor(0.9 * buy_price); max_pred = Math.ceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred ) { + if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -177,7 +171,7 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred ) { + if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -204,7 +198,7 @@ function* generate_pattern_0_with_lengths(given_prices, high_phase_1_len, dec_ph min_pred = Math.floor(0.9 * buy_price); max_pred = Math.ceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred ) { + if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -228,7 +222,6 @@ function* generate_pattern_0(given_prices) { /* decPhaseLen1 = randbool() ? 3 : 2; decPhaseLen2 = 5 - decPhaseLen1; - hiPhaseLen1 = randint(0, 6); hiPhaseLen2and3 = 7 - hiPhaseLen1; hiPhaseLen3 = randint(0, hiPhaseLen2and3 - 1); @@ -285,7 +278,7 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred ) { + if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -312,7 +305,7 @@ function* generate_pattern_1_with_peak(given_prices, peak_start) { max_pred = Math.ceil(max_randoms[i - peak_start] * buy_price); if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred ) { + if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -373,7 +366,7 @@ function* generate_pattern_2(given_prices) { if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred ) { + if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -403,7 +396,6 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { /* // PATTERN 3: decreasing, spike, decreasing peakStart = randint(2, 9); - // decreasing phase before the peak rate = randfloat(0.9, 0.4); for (work = 2; work < peakStart; work++) @@ -412,14 +404,12 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { rate -= 0.03; rate -= randfloat(0, 0.02); } - sellPrices[work++] = intceil(randfloat(0.9, 1.4) * (float)basePrice); sellPrices[work++] = intceil(randfloat(0.9, 1.4) * basePrice); rate = randfloat(1.4, 2.0); sellPrices[work++] = intceil(randfloat(1.4, rate) * basePrice) - 1; sellPrices[work++] = intceil(rate * basePrice); sellPrices[work++] = intceil(randfloat(1.4, rate) * basePrice) - 1; - // decreasing phase after the peak if (work < 14) { @@ -454,7 +444,7 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred ) { + if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -475,11 +465,11 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { // The peak - for (var i = peak_start; i < peak_start+2; i++) { + for (var i = peak_start; i < peak_start + 2; i++) { min_pred = Math.floor(0.9 * buy_price); max_pred = Math.ceil(1.4 * buy_price); if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred ) { + if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -496,13 +486,13 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { // Main spike 1 min_pred = Math.floor(1.4 * buy_price) - 1; max_pred = Math.ceil(2.0 * buy_price) - 1; - if (!isNaN(given_prices[peak_start+2])) { - if (given_prices[peak_start+2] < min_pred || given_prices[peak_start+2] > max_pred ) { + if (!isNaN(given_prices[peak_start + 2])) { + if (given_prices[peak_start + 2] < min_pred || given_prices[peak_start + 2] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } - min_pred = given_prices[peak_start+2]; - max_pred = given_prices[peak_start+2]; + min_pred = given_prices[peak_start + 2]; + max_pred = given_prices[peak_start + 2]; } predicted_prices.push({ min: min_pred, @@ -510,15 +500,15 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { }); // Main spike 2 - min_pred = predicted_prices[peak_start+2].min; + min_pred = predicted_prices[peak_start + 2].min; max_pred = Math.ceil(2.0 * buy_price); - if (!isNaN(given_prices[peak_start+3])) { - if (given_prices[peak_start+3] < min_pred || given_prices[peak_start+3] > max_pred ) { + if (!isNaN(given_prices[peak_start + 3])) { + if (given_prices[peak_start + 3] < min_pred || given_prices[peak_start + 3] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } - min_pred = given_prices[peak_start+3]; - max_pred = given_prices[peak_start+3]; + min_pred = given_prices[peak_start + 3]; + max_pred = given_prices[peak_start + 3]; } predicted_prices.push({ min: min_pred, @@ -527,31 +517,31 @@ function* generate_pattern_3_with_peak(given_prices, peak_start) { // Main spike 3 min_pred = Math.floor(1.4 * buy_price) - 1; - max_pred = predicted_prices[peak_start+3].max - 1; - if (!isNaN(given_prices[peak_start+4])) { - if (given_prices[peak_start+4] < min_pred || given_prices[peak_start+4] > max_pred ) { + max_pred = predicted_prices[peak_start + 3].max - 1; + if (!isNaN(given_prices[peak_start + 4])) { + if (given_prices[peak_start + 4] < min_pred || given_prices[peak_start + 4] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } - min_pred = given_prices[peak_start+4]; - max_pred = given_prices[peak_start+4]; + min_pred = given_prices[peak_start + 4]; + max_pred = given_prices[peak_start + 4]; } predicted_prices.push({ min: min_pred, max: max_pred, }); - if (peak_start+5 < 14) { + if (peak_start + 5 < 14) { var min_rate = 4000; var max_rate = 9000; - for (var i = peak_start+5; i < 14; i++) { + for (var i = peak_start + 5; i < 14; i++) { min_pred = Math.floor(min_rate * buy_price / 10000); max_pred = Math.ceil(max_rate * buy_price / 10000); if (!isNaN(given_prices[i])) { - if (given_prices[i] < min_pred || given_prices[i] > max_pred ) { + if (given_prices[i] < min_pred || given_prices[i] > max_pred) { // Given price is out of predicted range, so this is the wrong pattern return; } @@ -660,7 +650,7 @@ function analyze_possibilities(sell_prices, first_buy, previous_pattern) { weekMins.push(day.min); weekMaxes.push(day.max); } - poss.weekGuaranteedMinimum = Math.max(...weekMins); + poss.weekGuaranteedMinimum = Math.max(...weekMins); poss.weekMax = Math.max(...weekMaxes); } diff --git a/js/scripts.js b/js/scripts.js index a3c4915..bc9907a 100644 --- a/js/scripts.js +++ b/js/scripts.js @@ -7,15 +7,41 @@ const getSellFields = function () { return fields } +const getFirstBuyRadios = function () { + return [ + $("#first-time-radio-no")[0], + $("#first-time-radio-yes")[0] + ]; +} + +const getPreviousPatternRadios = function () { + return [ + $("#pattern-radio-unknown")[0], + $("#pattern-radio-fluctuating")[0], + $("#pattern-radio-small-spike")[0], + $("#pattern-radio-large-spike")[0], + $("#pattern-radio-decreasing")[0] + ]; +} + +const getCheckedRadio = function (radio_array) { + return radio_array.find(radio => radio.checked === true).value +} + +const checkRadioByValue = function (radio_array, value) { + radio_array.forEach(radio => radio.checked = false) + radio_array.find(radio => radio.value == value).checked = true +} + const sell_inputs = getSellFields() const buy_input = $("#buy") -const first_buy_field = $("#first_buy"); -const previous_pattern_input = $("#previous_pattern"); +const first_buy_radios = getFirstBuyRadios() +const previous_pattern_radios = getPreviousPatternRadios() //Functions const fillFields = function (prices, first_buy, previous_pattern) { - first_buy_field.prop("checked", first_buy); - previous_pattern_input.val(previous_pattern); + first_buy == 'yes' ? checkRadioByValue(first_buy_radios, 'yes') : checkRadioByValue(first_buy_radios, 'no') + checkRadioByValue(previous_pattern_radios, previous_pattern); buy_input.focus(); buy_input.val(prices[0] || '') @@ -50,9 +76,9 @@ const initialize = function () { } $("#reset").on("click", function () { - first_buy_field.prop('checked', false); - $("select").val(null); - $("input").val(null).trigger("input"); + sell_inputs.forEach(input => input.value = '') + fillFields([], false, 'unknown') + update() }) $('select').formSelect(); @@ -75,11 +101,11 @@ const isEmpty = function (arr) { } const getFirstBuyState = function () { - return JSON.parse(localStorage.getItem('first_buy')) + return JSON.parse(localStorage.getItem('first_buy')) || 'no' } const getPreviousPatternState = function () { - return JSON.parse(localStorage.getItem('previous_pattern')) + return JSON.parse(localStorage.getItem('previous_pattern')) || 'unknown' } const getPricesFromLocalstorage = function () { @@ -130,37 +156,61 @@ const calculateOutput = function (data, first_buy, previous_pattern) { } let output_possibilities = ""; for (let poss of analyze_possibilities(data, first_buy, previous_pattern)) { - var out_line = "" + poss.pattern_description + "" + var out_line = "" + poss.pattern_description + "" + console.log(poss.probability) out_line += `${Number.isFinite(poss.probability) ? ((poss.probability * 100).toPrecision(3) + '%') : '—'}`; for (let day of poss.prices.slice(1)) { if (day.min !== day.max) { - out_line += `${day.min}..${day.max}`; + out_line += `${day.min} to ${day.max}`; } else { - out_line += `${day.min}`; + out_line += `${day.min}`; } } - out_line += `${poss.weekGuaranteedMinimum}${poss.weekMax}`; + out_line += `${poss.weekGuaranteedMinimum}${poss.weekMax}`; output_possibilities += out_line } $("#output").html(output_possibilities) } +const convertPatternToInt = function (pattern) { + switch (pattern) { + case 'unknown': + return -1; + case 'fluctuating': + return 0; + case 'large-spike': + return 1; + case 'decreasing': + return 2; + case 'small-spike': + return 3; + default: + return -1; + } +} + + const update = function () { const sell_prices = getSellPrices(); const buy_price = parseInt(buy_input.val()); - const first_buy = first_buy_field.is(":checked"); - const previous_pattern = parseInt(previous_pattern_input.val()); + const first_buy = getCheckedRadio(first_buy_radios); + const first_buy_boolean = first_buy == 'yes' + const previous_pattern = getCheckedRadio(previous_pattern_radios); - buy_input.prop('disabled', first_buy); + + buy_input[0].disabled = first_buy_boolean; + buy_input[0].placeholder = first_buy_boolean ? '—' : '...' const prices = [buy_price, buy_price, ...sell_prices]; + if (!window.price_from_query) { updateLocalStorage(prices, first_buy, previous_pattern); } - calculateOutput(prices, first_buy, previous_pattern); + + calculateOutput(prices, first_buy_boolean, parseInt(convertPatternToInt(previous_pattern))); } $(document).ready(initialize); $(document).on("input", update); -$(previous_pattern_input).on("change", update); +$('input[type = radio]').on("change", update);