| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 1 | /* |
| 2 | * Copyright (C) 2016 The Android Open Source Project |
| 3 | * |
| 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file |
| 5 | * except in compliance with the License. You may obtain a copy of the License at |
| 6 | * |
| 7 | * http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | * |
| 9 | * Unless required by applicable law or agreed to in writing, software distributed under the |
| 10 | * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 11 | * KIND, either express or implied. See the License for the specific language governing |
| 12 | * permissions and limitations under the License. |
| 13 | */ |
| 14 | |
| 15 | package com.android.settings.graph; |
| 16 | |
| 17 | import android.annotation.Nullable; |
| 18 | import android.content.Context; |
| 19 | import android.content.res.Resources; |
| 20 | import android.graphics.Canvas; |
| 21 | import android.graphics.CornerPathEffect; |
| 22 | import android.graphics.DashPathEffect; |
| 23 | import android.graphics.LinearGradient; |
| 24 | import android.graphics.Paint; |
| 25 | import android.graphics.Paint.Cap; |
| 26 | import android.graphics.Paint.Join; |
| 27 | import android.graphics.Paint.Style; |
| 28 | import android.graphics.Path; |
| 29 | import android.graphics.Shader.TileMode; |
| 30 | import android.graphics.drawable.Drawable; |
| Aurimas Liutikas | 03fcde3 | 2018-04-17 11:22:43 -0700 | [diff] [blame] | 31 | import androidx.annotation.VisibleForTesting; |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 32 | import android.util.AttributeSet; |
| 33 | import android.util.SparseIntArray; |
| 34 | import android.util.TypedValue; |
| 35 | import android.view.View; |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 36 | |
| Salvador Martinez | 7961627 | 2017-07-19 17:14:21 -0700 | [diff] [blame] | 37 | import com.android.settings.fuelgauge.BatteryUtils; |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 38 | import com.android.settingslib.R; |
| 39 | |
| 40 | public class UsageGraph extends View { |
| 41 | |
| 42 | private static final int PATH_DELIM = -1; |
| Salvador Martinez | 7961627 | 2017-07-19 17:14:21 -0700 | [diff] [blame] | 43 | public static final String LOG_TAG = "UsageGraph"; |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 44 | |
| 45 | private final Paint mLinePaint; |
| 46 | private final Paint mFillPaint; |
| 47 | private final Paint mDottedPaint; |
| 48 | |
| 49 | private final Drawable mDivider; |
| 50 | private final Drawable mTintedDivider; |
| 51 | private final int mDividerSize; |
| 52 | |
| 53 | private final Path mPath = new Path(); |
| 54 | |
| 55 | // Paths in coordinates they are passed in. |
| 56 | private final SparseIntArray mPaths = new SparseIntArray(); |
| 57 | // Paths in local coordinates for drawing. |
| 58 | private final SparseIntArray mLocalPaths = new SparseIntArray(); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 59 | |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 60 | // Paths for projection in coordinates they are passed in. |
| 61 | private final SparseIntArray mProjectedPaths = new SparseIntArray(); |
| 62 | // Paths for projection in local coordinates for drawing. |
| 63 | private final SparseIntArray mLocalProjectedPaths = new SparseIntArray(); |
| 64 | |
| 65 | private final int mCornerRadius; |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 66 | private int mAccentColor; |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 67 | |
| 68 | private float mMaxX = 100; |
| 69 | private float mMaxY = 100; |
| 70 | |
| 71 | private float mMiddleDividerLoc = .5f; |
| 72 | private int mMiddleDividerTint = -1; |
| 73 | private int mTopDividerTint = -1; |
| 74 | |
| 75 | public UsageGraph(Context context, @Nullable AttributeSet attrs) { |
| 76 | super(context, attrs); |
| 77 | final Resources resources = context.getResources(); |
| 78 | |
| 79 | mLinePaint = new Paint(); |
| 80 | mLinePaint.setStyle(Style.STROKE); |
| 81 | mLinePaint.setStrokeCap(Cap.ROUND); |
| 82 | mLinePaint.setStrokeJoin(Join.ROUND); |
| 83 | mLinePaint.setAntiAlias(true); |
| 84 | mCornerRadius = resources.getDimensionPixelSize(R.dimen.usage_graph_line_corner_radius); |
| 85 | mLinePaint.setPathEffect(new CornerPathEffect(mCornerRadius)); |
| 86 | mLinePaint.setStrokeWidth(resources.getDimensionPixelSize(R.dimen.usage_graph_line_width)); |
| 87 | |
| 88 | mFillPaint = new Paint(mLinePaint); |
| 89 | mFillPaint.setStyle(Style.FILL); |
| 90 | |
| 91 | mDottedPaint = new Paint(mLinePaint); |
| 92 | mDottedPaint.setStyle(Style.STROKE); |
| 93 | float dots = resources.getDimensionPixelSize(R.dimen.usage_graph_dot_size); |
| 94 | float interval = resources.getDimensionPixelSize(R.dimen.usage_graph_dot_interval); |
| 95 | mDottedPaint.setStrokeWidth(dots * 3); |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 96 | mDottedPaint.setPathEffect(new DashPathEffect(new float[] {dots, interval}, 0)); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 97 | mDottedPaint.setColor(context.getColor(R.color.usage_graph_dots)); |
| 98 | |
| 99 | TypedValue v = new TypedValue(); |
| 100 | context.getTheme().resolveAttribute(com.android.internal.R.attr.listDivider, v, true); |
| 101 | mDivider = context.getDrawable(v.resourceId); |
| 102 | mTintedDivider = context.getDrawable(v.resourceId); |
| 103 | mDividerSize = resources.getDimensionPixelSize(R.dimen.usage_graph_divider_size); |
| 104 | } |
| 105 | |
| 106 | void clearPaths() { |
| 107 | mPaths.clear(); |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 108 | mLocalPaths.clear(); |
| 109 | mProjectedPaths.clear(); |
| 110 | mLocalProjectedPaths.clear(); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 111 | } |
| 112 | |
| 113 | void setMax(int maxX, int maxY) { |
| Salvador Martinez | 7961627 | 2017-07-19 17:14:21 -0700 | [diff] [blame] | 114 | final long startTime = System.currentTimeMillis(); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 115 | mMaxX = maxX; |
| 116 | mMaxY = maxY; |
| Alex Kulesza | 82dbcd9 | 2017-06-29 12:26:59 -0400 | [diff] [blame] | 117 | calculateLocalPaths(); |
| 118 | postInvalidate(); |
| Salvador Martinez | 7961627 | 2017-07-19 17:14:21 -0700 | [diff] [blame] | 119 | BatteryUtils.logRuntime(LOG_TAG, "setMax", startTime); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 120 | } |
| 121 | |
| 122 | void setDividerLoc(int height) { |
| 123 | mMiddleDividerLoc = 1 - height / mMaxY; |
| 124 | } |
| 125 | |
| 126 | void setDividerColors(int middleColor, int topColor) { |
| 127 | mMiddleDividerTint = middleColor; |
| 128 | mTopDividerTint = topColor; |
| 129 | } |
| 130 | |
| 131 | public void addPath(SparseIntArray points) { |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 132 | addPathAndUpdate(points, mPaths, mLocalPaths); |
| 133 | } |
| 134 | |
| 135 | public void addProjectedPath(SparseIntArray points) { |
| 136 | addPathAndUpdate(points, mProjectedPaths, mLocalProjectedPaths); |
| 137 | } |
| 138 | |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 139 | private void addPathAndUpdate( |
| 140 | SparseIntArray points, SparseIntArray paths, SparseIntArray localPaths) { |
| Salvador Martinez | 7961627 | 2017-07-19 17:14:21 -0700 | [diff] [blame] | 141 | final long startTime = System.currentTimeMillis(); |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 142 | for (int i = 0, size = points.size(); i < size; i++) { |
| 143 | paths.put(points.keyAt(i), points.valueAt(i)); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 144 | } |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 145 | // Add a delimiting value immediately after the last point. |
| 146 | paths.put(points.keyAt(points.size() - 1) + 1, PATH_DELIM); |
| 147 | calculateLocalPaths(paths, localPaths); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 148 | postInvalidate(); |
| Salvador Martinez | 7961627 | 2017-07-19 17:14:21 -0700 | [diff] [blame] | 149 | BatteryUtils.logRuntime(LOG_TAG, "addPathAndUpdate", startTime); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 150 | } |
| 151 | |
| 152 | void setAccentColor(int color) { |
| 153 | mAccentColor = color; |
| 154 | mLinePaint.setColor(mAccentColor); |
| 155 | updateGradient(); |
| 156 | postInvalidate(); |
| 157 | } |
| 158 | |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 159 | @Override |
| 160 | protected void onSizeChanged(int w, int h, int oldw, int oldh) { |
| Salvador Martinez | 7961627 | 2017-07-19 17:14:21 -0700 | [diff] [blame] | 161 | final long startTime = System.currentTimeMillis(); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 162 | super.onSizeChanged(w, h, oldw, oldh); |
| 163 | updateGradient(); |
| Alex Kulesza | 82dbcd9 | 2017-06-29 12:26:59 -0400 | [diff] [blame] | 164 | calculateLocalPaths(); |
| Salvador Martinez | 7961627 | 2017-07-19 17:14:21 -0700 | [diff] [blame] | 165 | BatteryUtils.logRuntime(LOG_TAG, "onSizeChanged", startTime); |
| Alex Kulesza | 82dbcd9 | 2017-06-29 12:26:59 -0400 | [diff] [blame] | 166 | } |
| 167 | |
| 168 | private void calculateLocalPaths() { |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 169 | calculateLocalPaths(mPaths, mLocalPaths); |
| 170 | calculateLocalPaths(mProjectedPaths, mLocalProjectedPaths); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 171 | } |
| 172 | |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 173 | @VisibleForTesting |
| 174 | void calculateLocalPaths(SparseIntArray paths, SparseIntArray localPaths) { |
| Salvador Martinez | 7961627 | 2017-07-19 17:14:21 -0700 | [diff] [blame] | 175 | final long startTime = System.currentTimeMillis(); |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 176 | if (getWidth() == 0) { |
| 177 | return; |
| 178 | } |
| 179 | localPaths.clear(); |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 180 | // Store the local coordinates of the most recent point. |
| 181 | int lx = 0; |
| 182 | int ly = PATH_DELIM; |
| 183 | boolean skippedLastPoint = false; |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 184 | for (int i = 0; i < paths.size(); i++) { |
| 185 | int x = paths.keyAt(i); |
| 186 | int y = paths.valueAt(i); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 187 | if (y == PATH_DELIM) { |
| Doris Ling | 02b4cf2 | 2018-03-28 17:14:23 -0700 | [diff] [blame] | 188 | if (i == 1) { |
| 189 | localPaths.put(getX(x+1) - 1, getY(0)); |
| 190 | continue; |
| 191 | } |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 192 | if (i == paths.size() - 1 && skippedLastPoint) { |
| 193 | // Add back skipped point to complete the path. |
| 194 | localPaths.put(lx, ly); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 195 | } |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 196 | skippedLastPoint = false; |
| 197 | localPaths.put(lx + 1, PATH_DELIM); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 198 | } else { |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 199 | lx = getX(x); |
| 200 | ly = getY(y); |
| 201 | // Skip this point if it is not far enough from the last one added. |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 202 | if (localPaths.size() > 0) { |
| 203 | int lastX = localPaths.keyAt(localPaths.size() - 1); |
| 204 | int lastY = localPaths.valueAt(localPaths.size() - 1); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 205 | if (lastY != PATH_DELIM && !hasDiff(lastX, lx) && !hasDiff(lastY, ly)) { |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 206 | skippedLastPoint = true; |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 207 | continue; |
| 208 | } |
| 209 | } |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 210 | skippedLastPoint = false; |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 211 | localPaths.put(lx, ly); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 212 | } |
| 213 | } |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 214 | BatteryUtils.logRuntime(LOG_TAG, "calculateLocalPaths", startTime); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 215 | } |
| 216 | |
| 217 | private boolean hasDiff(int x1, int x2) { |
| 218 | return Math.abs(x2 - x1) >= mCornerRadius; |
| 219 | } |
| 220 | |
| 221 | private int getX(float x) { |
| 222 | return (int) (x / mMaxX * getWidth()); |
| 223 | } |
| 224 | |
| 225 | private int getY(float y) { |
| 226 | return (int) (getHeight() * (1 - (y / mMaxY))); |
| 227 | } |
| 228 | |
| 229 | private void updateGradient() { |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 230 | mFillPaint.setShader( |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 231 | new LinearGradient( |
| 232 | 0, 0, 0, getHeight(), getColor(mAccentColor, .2f), 0, TileMode.CLAMP)); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 233 | } |
| 234 | |
| 235 | private int getColor(int color, float alphaScale) { |
| 236 | return (color & (((int) (0xff * alphaScale) << 24) | 0xffffff)); |
| 237 | } |
| 238 | |
| 239 | @Override |
| 240 | protected void onDraw(Canvas canvas) { |
| Salvador Martinez | 7961627 | 2017-07-19 17:14:21 -0700 | [diff] [blame] | 241 | final long startTime = System.currentTimeMillis(); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 242 | // Draw lines across the top, middle, and bottom. |
| 243 | if (mMiddleDividerLoc != 0) { |
| 244 | drawDivider(0, canvas, mTopDividerTint); |
| 245 | } |
| Alex Kulesza | 4a121ec | 2017-07-30 18:11:24 -0400 | [diff] [blame] | 246 | drawDivider( |
| 247 | (int) ((canvas.getHeight() - mDividerSize) * mMiddleDividerLoc), |
| 248 | canvas, |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 249 | mMiddleDividerTint); |
| 250 | drawDivider(canvas.getHeight() - mDividerSize, canvas, -1); |
| 251 | |
| Alex Kulesza | 82dbcd9 | 2017-06-29 12:26:59 -0400 | [diff] [blame] | 252 | if (mLocalPaths.size() == 0 && mLocalProjectedPaths.size() == 0) { |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 253 | return; |
| 254 | } |
| Alex Kulesza | 82dbcd9 | 2017-06-29 12:26:59 -0400 | [diff] [blame] | 255 | |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 256 | drawLinePath(canvas, mLocalProjectedPaths, mDottedPaint); |
| 257 | drawFilledPath(canvas, mLocalPaths, mFillPaint); |
| 258 | drawLinePath(canvas, mLocalPaths, mLinePaint); |
| Salvador Martinez | 7961627 | 2017-07-19 17:14:21 -0700 | [diff] [blame] | 259 | BatteryUtils.logRuntime(LOG_TAG, "onDraw", startTime); |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 260 | } |
| 261 | |
| 262 | private void drawLinePath(Canvas canvas, SparseIntArray localPaths, Paint paint) { |
| 263 | if (localPaths.size() == 0) { |
| 264 | return; |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 265 | } |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 266 | mPath.reset(); |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 267 | mPath.moveTo(localPaths.keyAt(0), localPaths.valueAt(0)); |
| 268 | for (int i = 1; i < localPaths.size(); i++) { |
| 269 | int x = localPaths.keyAt(i); |
| 270 | int y = localPaths.valueAt(i); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 271 | if (y == PATH_DELIM) { |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 272 | if (++i < localPaths.size()) { |
| 273 | mPath.moveTo(localPaths.keyAt(i), localPaths.valueAt(i)); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 274 | } |
| 275 | } else { |
| 276 | mPath.lineTo(x, y); |
| 277 | } |
| 278 | } |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 279 | canvas.drawPath(mPath, paint); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 280 | } |
| 281 | |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 282 | private void drawFilledPath(Canvas canvas, SparseIntArray localPaths, Paint paint) { |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 283 | mPath.reset(); |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 284 | float lastStartX = localPaths.keyAt(0); |
| 285 | mPath.moveTo(localPaths.keyAt(0), localPaths.valueAt(0)); |
| 286 | for (int i = 1; i < localPaths.size(); i++) { |
| 287 | int x = localPaths.keyAt(i); |
| 288 | int y = localPaths.valueAt(i); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 289 | if (y == PATH_DELIM) { |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 290 | mPath.lineTo(localPaths.keyAt(i - 1), getHeight()); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 291 | mPath.lineTo(lastStartX, getHeight()); |
| 292 | mPath.close(); |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 293 | if (++i < localPaths.size()) { |
| 294 | lastStartX = localPaths.keyAt(i); |
| 295 | mPath.moveTo(localPaths.keyAt(i), localPaths.valueAt(i)); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 296 | } |
| 297 | } else { |
| 298 | mPath.lineTo(x, y); |
| 299 | } |
| 300 | } |
| Alex Kulesza | c57ceaa | 2017-06-06 19:24:48 -0400 | [diff] [blame] | 301 | canvas.drawPath(mPath, paint); |
| Alex Kulesza | c661098 | 2017-06-02 18:16:28 -0400 | [diff] [blame] | 302 | } |
| 303 | |
| 304 | private void drawDivider(int y, Canvas canvas, int tintColor) { |
| 305 | Drawable d = mDivider; |
| 306 | if (tintColor != -1) { |
| 307 | mTintedDivider.setTint(tintColor); |
| 308 | d = mTintedDivider; |
| 309 | } |
| 310 | d.setBounds(0, y, canvas.getWidth(), y + mDividerSize); |
| 311 | d.draw(canvas); |
| 312 | } |
| 313 | } |